mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:50:43 +00:00
fix(plugins): install external search plugins during onboarding
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
168
src/plugins/web-search-install-catalog.ts
Normal file
168
src/plugins/web-search-install-catalog.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user