fix(plugins): install external search plugins during onboarding

This commit is contained in:
Vincent Koc
2026-05-02 13:57:52 -07:00
parent 63a3a0e1ec
commit 46d4238425
13 changed files with 686 additions and 13 deletions

View File

@@ -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.

View File

@@ -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 <path>",
"description": "Google Chat webhook path"
},
{
"flags": "--webhook-url <url>",
"description": "Google Chat webhook URL"
},
{
"flags": "--audience-type <type>",
"description": "Google Chat audience type (app-url|project-number)"
},
{
"flags": "--audience <value>",
"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",

View File

@@ -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",

View File

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

View File

@@ -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<string> {
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;
}

View File

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

View File

@@ -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<string>, 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) &&

View File

@@ -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,

View File

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

View File

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

View File

@@ -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<OpenClawConfig> {
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;
}

View File

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

View File

@@ -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<string, unknown> = target as Record<string, unknown>;
for (const segment of segments.slice(0, -1)) {
const next = current[segment];
if (!isRecord(next)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
}
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<string, unknown>) => searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget: Record<string, unknown>, 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),
);
}