From 46d4238425cafc18ad723ac95b7c24021c7a1263 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 13:57:52 -0700 Subject: [PATCH] fix(plugins): install external search plugins during onboarding --- CHANGELOG.md | 1 + .../official-external-channel-catalog.json | 75 ++++++++ .../lib/official-external-plugin-catalog.json | 32 ++++ .../missing-configured-plugin-install.test.ts | 169 ++++++++++++++++++ .../missing-configured-plugin-install.ts | 23 +++ ...release-configured-plugin-installs.test.ts | 24 +++ .../release-configured-plugin-installs.ts | 30 ++++ src/commands/onboard-search.providers.test.ts | 5 + src/config/validation.ts | 12 +- src/flows/search-setup.test.ts | 75 +++++++- src/flows/search-setup.ts | 69 ++++++- .../official-external-plugin-catalog.ts | 16 ++ src/plugins/web-search-install-catalog.ts | 168 +++++++++++++++++ 13 files changed, 686 insertions(+), 13 deletions(-) create mode 100644 src/plugins/web-search-install-catalog.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2114e410b4b..a641646e01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/search: install official external web-search plugins such as Brave before saving provider config, and make doctor repair reconcile selected external search providers whose npm payload is missing. Thanks @vincentkoc. - Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins, keep unpublished ACPX/Google Chat/LINE bundled, and make missing-plugin repair honor npm-first metadata while ClawHub pack files roll out. Thanks @vincentkoc. - Plugins/update: detect tracked plugin install records whose package directories disappeared during `openclaw update`, reinstall them before normal plugin updates, and fail the update if any install record still points at missing disk payloads. - CLI/infer: reject local `codex/*` one-shot model probes before simple-completion dispatch and point operators at the Codex app-server runtime path instead of ending with an empty-output error. diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 0e302323270..f5bfccdb6aa 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -123,6 +123,81 @@ } } }, + { + "name": "@openclaw/googlechat", + "description": "OpenClaw Google Chat channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "googlechat", + "label": "Google Chat", + "selectionLabel": "Google Chat (Chat API)", + "detailLabel": "Google Chat", + "docsPath": "/channels/googlechat", + "docsLabel": "googlechat", + "blurb": "Google Workspace Chat app with HTTP webhook.", + "aliases": ["gchat", "google-chat"], + "order": 55, + "systemImage": "message.badge", + "markdownCapable": true, + "doctorCapabilities": { + "dmAllowFromMode": "nestedOnly", + "groupModel": "route", + "groupAllowFromFallbackToAllowFrom": false, + "warnOnEmptyGroupSenderAllowlist": false + }, + "cliAddOptions": [ + { + "flags": "--webhook-path ", + "description": "Google Chat webhook path" + }, + { + "flags": "--webhook-url ", + "description": "Google Chat webhook URL" + }, + { + "flags": "--audience-type ", + "description": "Google Chat audience type (app-url|project-number)" + }, + { + "flags": "--audience ", + "description": "Google Chat audience value (app URL or project number)" + } + ] + }, + "install": { + "npmSpec": "@openclaw/googlechat", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/line", + "description": "OpenClaw LINE channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "line", + "label": "LINE", + "selectionLabel": "LINE (Messaging API)", + "detailLabel": "LINE Bot", + "docsPath": "/channels/line", + "docsLabel": "line", + "blurb": "LINE Messaging API webhook bot.", + "systemImage": "message", + "order": 75, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/line", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, { "name": "@openclaw/matrix", "description": "OpenClaw Matrix channel plugin", diff --git a/scripts/lib/official-external-plugin-catalog.json b/scripts/lib/official-external-plugin-catalog.json index d29d85ebda7..dd2c50a46d7 100644 --- a/scripts/lib/official-external-plugin-catalog.json +++ b/scripts/lib/official-external-plugin-catalog.json @@ -1,5 +1,22 @@ { "entries": [ + { + "name": "@openclaw/acpx", + "description": "OpenClaw ACP runtime backend", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "acpx", + "label": "ACPX Runtime" + }, + "install": { + "npmSpec": "@openclaw/acpx", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.25" + } + } + }, { "name": "@openclaw/brave-plugin", "description": "OpenClaw Brave plugin", @@ -10,6 +27,21 @@ "id": "brave", "label": "Brave" }, + "webSearchProviders": [ + { + "id": "brave", + "label": "Brave Search", + "hint": "Brave Search web results.", + "onboardingScopes": ["text-inference"], + "credentialLabel": "Brave Search API key", + "envVars": ["BRAVE_API_KEY"], + "placeholder": "BSA...", + "signupUrl": "https://api-dashboard.search.brave.com/app/keys", + "docsUrl": "https://docs.openclaw.ai/tools/brave-search", + "credentialPath": "plugins.entries.brave.config.webSearch.apiKey", + "autoDetectOrder": 10 + } + ], "install": { "npmSpec": "@openclaw/brave-plugin", "defaultChoice": "npm", diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 2a8ed0c2e3b..7e982c6218d 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -7,6 +7,9 @@ const mocks = vi.hoisted(() => ({ listOfficialExternalPluginCatalogEntries: vi.fn(), loadInstalledPluginIndexInstallRecords: vi.fn(), loadPluginMetadataSnapshot: vi.fn(), + getOfficialExternalPluginCatalogManifest: vi.fn( + (entry: { openclaw?: unknown }) => entry.openclaw, + ), resolveOfficialExternalPluginId: vi.fn((entry: { id?: string }) => entry.id), resolveOfficialExternalPluginInstall: vi.fn( (entry: { install?: unknown }) => entry.install ?? null, @@ -51,6 +54,7 @@ vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({ })); vi.mock("../../../plugins/official-external-plugin-catalog.js", () => ({ + getOfficialExternalPluginCatalogManifest: mocks.getOfficialExternalPluginCatalogManifest, listOfficialExternalPluginCatalogEntries: mocks.listOfficialExternalPluginCatalogEntries, resolveOfficialExternalPluginId: mocks.resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall: mocks.resolveOfficialExternalPluginInstall, @@ -522,4 +526,169 @@ describe("repairMissingConfiguredPluginInstalls", () => { ); expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']); }); + + it("reinstalls a recorded external web search plugin from provider-only config", async () => { + const records = { + brave: { + source: "npm", + spec: "@openclaw/brave-plugin@beta", + installPath: "/missing/brave", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "brave", + label: "Brave", + install: { + npmSpec: "@openclaw/brave-plugin", + defaultChoice: "npm", + }, + openclaw: { + plugin: { id: "brave", label: "Brave" }, + webSearchProviders: [ + { + id: "brave", + label: "Brave Search", + hint: "Brave Search", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://example.test/brave", + }, + ], + }, + }, + ]); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: true, + config: { + plugins: { + installs: { + brave: { + source: "npm", + spec: "@openclaw/brave-plugin@beta", + installPath: "/tmp/openclaw-plugins/brave", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "brave", + status: "updated", + message: "Updated brave.", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + tools: { + web: { + search: { + provider: "brave", + }, + }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["brave"], + config: expect.objectContaining({ + plugins: expect.objectContaining({ installs: records }), + }), + }), + ); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + expect.objectContaining({ + brave: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/brave" }), + }), + { env: {} }, + ); + expect(result.changes).toEqual(['Repaired missing configured plugin "brave".']); + }); + + it("installs a configured external web search plugin from provider-only config", async () => { + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "brave", + label: "Brave", + install: { + npmSpec: "@openclaw/brave-plugin", + defaultChoice: "npm", + }, + openclaw: { + plugin: { id: "brave", label: "Brave" }, + webSearchProviders: [ + { + id: "brave", + label: "Brave Search", + hint: "Brave Search", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://example.test/brave", + credentialPath: "plugins.entries.brave.config.webSearch.apiKey", + }, + ], + install: { + npmSpec: "@openclaw/brave-plugin", + defaultChoice: "npm", + }, + }, + }, + ]); + mocks.resolveOfficialExternalPluginId.mockImplementation( + (entry: { id?: string; openclaw?: { plugin?: { id?: string } } }) => + entry.openclaw?.plugin?.id ?? entry.id, + ); + mocks.resolveOfficialExternalPluginInstall.mockImplementation( + (entry: { install?: unknown; openclaw?: { install?: unknown } }) => + entry.openclaw?.install ?? entry.install ?? null, + ); + mocks.resolveOfficialExternalPluginLabel.mockImplementation( + (entry: { label?: string; openclaw?: { plugin?: { label?: string } } }) => + entry.openclaw?.plugin?.label ?? entry.label ?? "plugin", + ); + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "brave", + targetDir: "/tmp/openclaw-plugins/brave", + version: "2026.5.2", + npmResolution: { + name: "@openclaw/brave-plugin", + version: "2026.5.2", + resolvedSpec: "@openclaw/brave-plugin@2026.5.2", + }, + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + tools: { + web: { + search: { + provider: "brave", + }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/brave-plugin", + expectedPluginId: "brave", + }), + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "brave" from @openclaw/brave-plugin.', + ]); + }); }); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index cab94214c65..837a10c2ace 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -19,6 +19,7 @@ import { } from "../../../plugins/official-external-plugin-catalog.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; +import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; import { asObjectRecord } from "./object.js"; type DownloadableInstallCandidate = { @@ -31,6 +32,11 @@ type DownloadableInstallCandidate = { }; const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[] = [ + { + pluginId: "acpx", + label: "ACPX Runtime", + npmSpec: "@openclaw/acpx", + }, // Runtime-only configs do not have a provider/channel integration catalog entry. { pluginId: "codex", @@ -87,6 +93,23 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig): Set { ids.add(pluginId.trim()); } } + const searchProvider = cfg.tools?.web?.search?.provider; + if (typeof searchProvider === "string") { + const installEntry = resolveWebSearchInstallCatalogEntry({ providerId: searchProvider }); + if (installEntry?.pluginId) { + ids.add(installEntry.pluginId); + } + } + const acp = asObjectRecord(cfg.acp); + const acpBackend = typeof acp?.backend === "string" ? acp.backend.trim().toLowerCase() : ""; + if ( + (acpBackend === "acpx" || + acp?.enabled === true || + asObjectRecord(acp?.dispatch)?.enabled === true) && + (!acpBackend || acpBackend === "acpx") + ) { + ids.add("acpx"); + } return ids; } diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 82477179f7e..2db87b5e384 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -160,6 +160,30 @@ describe("configured plugin install release step", () => { expect(result.channelIds).toEqual([]); }); + it("collects external web search and ACP runtime plugins from config-only usage", async () => { + const { collectReleaseConfiguredPluginIds } = + await import("./release-configured-plugin-installs.js"); + const result = collectReleaseConfiguredPluginIds({ + cfg: { + acp: { + enabled: true, + backend: "acpx", + }, + tools: { + web: { + search: { + provider: "brave", + }, + }, + }, + }, + env: {}, + }); + + expect(result.pluginIds).toEqual(["acpx", "brave"]); + expect(result.channelIds).toEqual([]); + }); + it("does not collect channel ids when the matching plugin id is blocked", async () => { const { collectReleaseConfiguredPluginIds } = await import("./release-configured-plugin-installs.js"); diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index cf1465ba451..e755bc60666 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -6,6 +6,7 @@ import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-en import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { compareOpenClawVersions } from "../../../config/version.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; +import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; import { VERSION } from "../../../version.js"; import { repairMissingPluginInstallsForIds } from "./missing-configured-plugin-install.js"; import { asObjectRecord } from "./object.js"; @@ -223,6 +224,29 @@ function collectAgentHarnessRuntimePluginIds( .toSorted((left, right) => left.localeCompare(right)); } +function collectWebSearchPluginIds(cfg: OpenClawConfig): string[] { + const providerId = cfg.tools?.web?.search?.provider; + if (typeof providerId !== "string") { + return []; + } + const entry = resolveWebSearchInstallCatalogEntry({ providerId }); + return entry?.pluginId ? [entry.pluginId] : []; +} + +function collectAcpRuntimePluginIds(cfg: OpenClawConfig): string[] { + const acp = asObjectRecord(cfg.acp); + if (!acp) { + return []; + } + const backend = normalizeId(acp.backend)?.toLowerCase() ?? ""; + const configured = + acp.enabled === true || asObjectRecord(acp.dispatch)?.enabled === true || backend === "acpx"; + if (!configured || (backend && backend !== "acpx")) { + return []; + } + return ["acpx"]; +} + function addEligiblePluginId(cfg: OpenClawConfig, pluginIds: Set, pluginId: string): void { const normalized = pluginId.trim(); if (!normalized || isDenied(cfg, normalized) || isDisabled(cfg, normalized)) { @@ -277,6 +301,12 @@ export function collectReleaseConfiguredPluginIds(params: { for (const pluginId of collectAgentHarnessRuntimePluginIds(params.cfg, env)) { addEligiblePluginId(params.cfg, pluginIds, pluginId); } + for (const pluginId of collectWebSearchPluginIds(params.cfg)) { + addEligiblePluginId(params.cfg, pluginIds, pluginId); + } + for (const pluginId of collectAcpRuntimePluginIds(params.cfg)) { + addEligiblePluginId(params.cfg, pluginIds, pluginId); + } for (const channelId of collectConfiguredChannelIds(params.cfg, env)) { if ( !isChannelDisabled(params.cfg, channelId) && diff --git a/src/commands/onboard-search.providers.test.ts b/src/commands/onboard-search.providers.test.ts index ba2e4377675..75a0d2b2afd 100644 --- a/src/commands/onboard-search.providers.test.ts +++ b/src/commands/onboard-search.providers.test.ts @@ -6,12 +6,17 @@ const mocks = vi.hoisted(() => ({ resolvePluginWebSearchProviders: vi.fn< (params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[] >(() => []), + resolveWebSearchInstallCatalogEntries: vi.fn(() => []), })); vi.mock("../plugins/web-search-providers.runtime.js", () => ({ resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders, })); +vi.mock("../plugins/web-search-install-catalog.js", () => ({ + resolveWebSearchInstallCatalogEntries: mocks.resolveWebSearchInstallCatalogEntries, +})); + function createCustomProviderEntry(): PluginWebSearchProviderEntry { return { id: "custom-search" as never, diff --git a/src/config/validation.ts b/src/config/validation.ts index 4c0fd7d424b..56c4eabd192 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -17,6 +17,7 @@ import { } from "../plugins/plugin-metadata-snapshot.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { hasKind } from "../plugins/slots.js"; +import { resolveWebSearchInstallCatalogEntries } from "../plugins/web-search-install-catalog.js"; import { collectLegacySecretRefEnvMarkerCandidates } from "../secrets/legacy-secretref-env-marker.js"; import { collectUnsupportedSecretRefConfigCandidates } from "../secrets/unsupported-surface-policy.js"; import { @@ -981,11 +982,12 @@ function validateConfigObjectWithPluginsBase( const { registry } = ensureRegistry(); return [ ...new Set( - registry.plugins.flatMap((record) => - (record.contracts?.webSearchProviders ?? []) - .map((providerId) => providerId.trim()) - .filter((providerId) => providerId.length > 0), - ), + [ + ...registry.plugins.flatMap((record) => record.contracts?.webSearchProviders ?? []), + ...resolveWebSearchInstallCatalogEntries().map((entry) => entry.provider.id), + ] + .map((providerId) => providerId.trim()) + .filter((providerId) => providerId.length > 0), ), ].toSorted((left, right) => left.localeCompare(right)); }; diff --git a/src/flows/search-setup.test.ts b/src/flows/search-setup.test.ts index 6147f3bab6e..e1db5425569 100644 --- a/src/flows/search-setup.test.ts +++ b/src/flows/search-setup.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { createNonExitingRuntime } from "../runtime.js"; import { runSearchSetupFlow } from "./search-setup.js"; @@ -97,7 +97,45 @@ vi.mock("../plugins/web-search-providers.runtime.js", () => ({ resolvePluginWebSearchProviders: () => [mockGrokProvider], })); +const ensureOnboardingPluginInstalled = vi.hoisted(() => + vi.fn( + async ({ + cfg, + entry, + }: { + cfg: { plugins?: { installs?: Record } }; + entry: { pluginId: string; install: { npmSpec?: string } }; + }) => ({ + cfg: { + ...cfg, + plugins: { + ...cfg.plugins, + installs: { + ...cfg.plugins?.installs, + [entry.pluginId]: { + source: "npm", + spec: entry.install.npmSpec, + installPath: `/tmp/openclaw-plugins/${entry.pluginId}`, + }, + }, + }, + }, + installed: true, + pluginId: entry.pluginId, + status: "installed", + }), + ), +); + +vi.mock("../commands/onboarding-plugin-install.js", () => ({ + ensureOnboardingPluginInstalled, +})); + describe("runSearchSetupFlow", () => { + beforeEach(() => { + ensureOnboardingPluginInstalled.mockClear(); + }); + it("runs provider-owned setup after selecting Grok web search", async () => { const select = vi .fn() @@ -249,4 +287,39 @@ describe("runSearchSetupFlow", () => { model: "grok-4-1-fast", }); }); + + it("installs an external catalog search provider before enabling it", async () => { + const select = vi.fn().mockResolvedValueOnce("brave"); + const text = vi.fn().mockResolvedValue("brave-test-key"); + const prompter = createWizardPrompter({ + select: select as never, + text: text as never, + }); + + const next = await runSearchSetupFlow({}, createNonExitingRuntime(), prompter); + + expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + entry: expect.objectContaining({ + pluginId: "brave", + label: "Brave", + install: expect.objectContaining({ + npmSpec: "@openclaw/brave-plugin", + }), + }), + autoConfirmSingleSource: true, + }), + ); + expect(next.tools?.web?.search).toMatchObject({ + provider: "brave", + enabled: true, + }); + expect(next.plugins?.entries?.brave?.config?.webSearch).toMatchObject({ + apiKey: "brave-test-key", + }); + expect(next.plugins?.installs?.brave).toMatchObject({ + source: "npm", + spec: "@openclaw/brave-plugin", + }); + }); }); diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts index 13c8cece965..e95cea439bf 100644 --- a/src/flows/search-setup.ts +++ b/src/flows/search-setup.ts @@ -7,8 +7,13 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; +import { + resolveWebSearchInstallCatalogEntries, + type WebSearchInstallCatalogEntry, +} from "../plugins/web-search-install-catalog.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import { sortWebSearchProviders } from "../plugins/web-search-providers.shared.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -32,7 +37,13 @@ type SearchProviderSetupContribution = FlowContribution & { surface: "setup"; provider: PluginWebSearchProviderEntry; option: SearchProviderSetupOption; - source: "runtime"; + source: "runtime" | "install-catalog"; +}; + +const SEARCH_INSTALL_CATALOG_ENTRY = Symbol("search-install-catalog-entry"); + +type SearchProviderEntryWithInstall = PluginWebSearchProviderEntry & { + [SEARCH_INSTALL_CATALOG_ENTRY]?: WebSearchInstallCatalogEntry; }; function resolveSearchProviderCredentialLabel( @@ -66,7 +77,7 @@ export function resolveSearchProviderOptions( function buildSearchProviderSetupContribution(params: { provider: PluginWebSearchProviderEntry; - source: "runtime"; + source: "runtime" | "install-catalog"; }): SearchProviderSetupContribution { return { id: `search:setup:${params.provider.id}`, @@ -86,17 +97,41 @@ function buildSearchProviderSetupContribution(params: { function resolveSearchProviderSetupContributions( config?: OpenClawConfig, ): SearchProviderSetupContribution[] { - const providers = sortWebSearchProviders( + const runtimeProviders = sortWebSearchProviders( resolvePluginWebSearchProviders({ config, env: process.env, mode: "setup", }), ); + const seenProviderIds = new Set(runtimeProviders.map((provider) => provider.id)); + const seenPluginIds = new Set(runtimeProviders.map((provider) => provider.pluginId)); + const normalizedPluginsConfig = normalizePluginsConfig(config?.plugins); + const installCatalogProviders = resolveWebSearchInstallCatalogEntries() + .filter( + (entry) => + !seenProviderIds.has(entry.provider.id) && + !seenPluginIds.has(entry.pluginId) && + resolveEffectiveEnableState({ + id: entry.pluginId, + origin: "global", + config: normalizedPluginsConfig, + rootConfig: config, + enabledByDefault: true, + }).enabled, + ) + .map( + (entry): SearchProviderEntryWithInstall => + Object.assign({}, entry.provider, { [SEARCH_INSTALL_CATALOG_ENTRY]: entry }), + ); + const providers = sortWebSearchProviders([...runtimeProviders, ...installCatalogProviders]); return sortFlowContributionsByLabel( - providers - .filter(showsSearchProviderInSetup) - .map((provider) => buildSearchProviderSetupContribution({ provider, source: "runtime" })), + providers.filter(showsSearchProviderInSetup).map((provider) => + buildSearchProviderSetupContribution({ + provider, + source: SEARCH_INSTALL_CATALOG_ENTRY in provider ? "install-catalog" : "runtime", + }), + ), ); } @@ -302,12 +337,32 @@ export type SetupSearchOptions = { async function finalizeSearchProviderSetup(params: { originalConfig: OpenClawConfig; nextConfig: OpenClawConfig; - entry: PluginWebSearchProviderEntry; + entry: SearchProviderEntryWithInstall; runtime: RuntimeEnv; prompter: WizardPrompter; opts?: SetupSearchOptions; }): Promise { let next = preserveDisabledState(params.originalConfig, params.nextConfig); + const installEntry = params.entry[SEARCH_INSTALL_CATALOG_ENTRY]; + if (installEntry && next.tools?.web?.search?.enabled !== false) { + const { ensureOnboardingPluginInstalled } = + await import("../commands/onboarding-plugin-install.js"); + const installed = await ensureOnboardingPluginInstalled({ + cfg: next, + entry: { + pluginId: installEntry.pluginId, + label: installEntry.label, + install: installEntry.install, + }, + prompter: params.prompter, + runtime: params.runtime, + autoConfirmSingleSource: true, + }); + if (!installed.installed) { + return params.originalConfig; + } + next = installed.cfg; + } if (!params.entry.runSetup) { return next; } diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index 7ac6556a62e..fb91b6221a7 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -33,6 +33,21 @@ export type OfficialExternalProviderCatalogProvider = { authChoices?: readonly OfficialExternalProviderAuthChoice[]; }; +export type OfficialExternalWebSearchProvider = { + id?: string; + label?: string; + hint?: string; + onboardingScopes?: readonly "text-inference"[]; + requiresCredential?: boolean; + credentialLabel?: string; + envVars?: readonly string[]; + placeholder?: string; + signupUrl?: string; + docsUrl?: string; + credentialPath?: string; + autoDetectOrder?: number; +}; + export type OfficialExternalPluginCatalogManifest = { plugin?: { id?: string; @@ -43,6 +58,7 @@ export type OfficialExternalPluginCatalogManifest = { label?: string; }; providers?: readonly OfficialExternalProviderCatalogProvider[]; + webSearchProviders?: readonly OfficialExternalWebSearchProvider[]; install?: PluginPackageInstall; }; diff --git a/src/plugins/web-search-install-catalog.ts b/src/plugins/web-search-install-catalog.ts new file mode 100644 index 00000000000..5668f38d621 --- /dev/null +++ b/src/plugins/web-search-install-catalog.ts @@ -0,0 +1,168 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isRecord } from "../utils.js"; +import { enablePluginInConfig } from "./enable.js"; +import type { PluginPackageInstall } from "./manifest.js"; +import { + getOfficialExternalPluginCatalogManifest, + listOfficialExternalPluginCatalogEntries, + resolveOfficialExternalPluginInstall, + resolveOfficialExternalPluginLabel, + type OfficialExternalWebSearchProvider, +} from "./official-external-plugin-catalog.js"; +import type { PluginWebSearchProviderEntry } from "./types.js"; + +export type WebSearchInstallCatalogEntry = { + pluginId: string; + label: string; + install: PluginPackageInstall; + provider: PluginWebSearchProviderEntry; +}; + +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeStringList(value: unknown): string[] { + return Array.isArray(value) + ? value.map(normalizeString).filter((entry): entry is string => Boolean(entry)) + : []; +} + +function normalizeOnboardingScopes( + value: OfficialExternalWebSearchProvider["onboardingScopes"], +): readonly "text-inference"[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const scopes = value.filter((entry): entry is "text-inference" => entry === "text-inference"); + return scopes.length > 0 ? scopes : undefined; +} + +function pathSegments(path: string): string[] { + return path + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +function getConfigPath(config: OpenClawConfig | undefined, path: string): unknown { + let current: unknown = config; + for (const segment of pathSegments(path)) { + if (!isRecord(current)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function setConfigPath(target: OpenClawConfig, path: string, value: unknown): void { + const segments = pathSegments(path); + let current: Record = target as Record; + for (const segment of segments.slice(0, -1)) { + const next = current[segment]; + if (!isRecord(next)) { + current[segment] = {}; + } + current = current[segment] as Record; + } + const leaf = segments.at(-1); + if (leaf) { + current[leaf] = value; + } +} + +function buildProviderEntry(params: { + pluginId: string; + provider: OfficialExternalWebSearchProvider; +}): PluginWebSearchProviderEntry | null { + const providerId = normalizeString(params.provider.id); + const label = normalizeString(params.provider.label); + const hint = normalizeString(params.provider.hint); + const credentialPath = + normalizeString(params.provider.credentialPath) ?? + `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + const envVars = normalizeStringList(params.provider.envVars); + const placeholder = normalizeString(params.provider.placeholder); + const signupUrl = normalizeString(params.provider.signupUrl); + if (!providerId || !label || !hint || envVars.length === 0 || !placeholder || !signupUrl) { + return null; + } + return { + id: providerId, + pluginId: params.pluginId, + label, + hint, + envVars, + placeholder, + signupUrl, + credentialPath, + ...(normalizeOnboardingScopes(params.provider.onboardingScopes) + ? { onboardingScopes: normalizeOnboardingScopes(params.provider.onboardingScopes) } + : {}), + ...(params.provider.requiresCredential === false ? { requiresCredential: false } : {}), + ...(normalizeString(params.provider.credentialLabel) + ? { credentialLabel: normalizeString(params.provider.credentialLabel) } + : {}), + ...(normalizeString(params.provider.docsUrl) + ? { docsUrl: normalizeString(params.provider.docsUrl) } + : {}), + ...(typeof params.provider.autoDetectOrder === "number" + ? { autoDetectOrder: params.provider.autoDetectOrder } + : {}), + getCredentialValue: (searchConfig?: Record) => searchConfig?.apiKey, + setCredentialValue: (searchConfigTarget: Record, value: unknown) => { + searchConfigTarget.apiKey = value; + }, + getConfiguredCredentialValue: (config?: OpenClawConfig) => + getConfigPath(config, credentialPath), + setConfiguredCredentialValue: (configTarget: OpenClawConfig, value: unknown) => { + setConfigPath(configTarget, credentialPath, value); + }, + applySelectionConfig: (config: OpenClawConfig) => + enablePluginInConfig(config, params.pluginId).config, + createTool: () => null, + }; +} + +export function resolveWebSearchInstallCatalogEntries(): WebSearchInstallCatalogEntry[] { + const entries: WebSearchInstallCatalogEntry[] = []; + for (const entry of listOfficialExternalPluginCatalogEntries()) { + const manifest = getOfficialExternalPluginCatalogManifest(entry); + const pluginId = normalizeString(manifest?.plugin?.id); + const install = resolveOfficialExternalPluginInstall(entry); + if (!manifest || !pluginId || !install) { + continue; + } + for (const provider of manifest.webSearchProviders ?? []) { + const providerEntry = buildProviderEntry({ pluginId, provider }); + if (!providerEntry) { + continue; + } + entries.push({ + pluginId, + label: resolveOfficialExternalPluginLabel(entry), + install, + provider: providerEntry, + }); + } + } + return entries.toSorted( + (left, right) => + left.provider.label.localeCompare(right.provider.label) || + left.provider.id.localeCompare(right.provider.id), + ); +} + +export function resolveWebSearchInstallCatalogEntry(params: { + providerId?: string; + pluginId?: string; +}): WebSearchInstallCatalogEntry | undefined { + const providerId = normalizeString(params.providerId); + const pluginId = normalizeString(params.pluginId); + return resolveWebSearchInstallCatalogEntries().find( + (entry) => + (!providerId || entry.provider.id === providerId) && + (!pluginId || entry.pluginId === pluginId), + ); +}