Skip to content

Commit 46d4238

Browse files
committed
fix(plugins): install external search plugins during onboarding
1 parent 63a3a0e commit 46d4238

13 files changed

Lines changed: 686 additions & 13 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- 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.
1516
- 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.
1617
- 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.
1718
- 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.

scripts/lib/official-external-channel-catalog.json

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,81 @@
123123
}
124124
}
125125
},
126+
{
127+
"name": "@openclaw/googlechat",
128+
"description": "OpenClaw Google Chat channel plugin",
129+
"source": "official",
130+
"kind": "channel",
131+
"openclaw": {
132+
"channel": {
133+
"id": "googlechat",
134+
"label": "Google Chat",
135+
"selectionLabel": "Google Chat (Chat API)",
136+
"detailLabel": "Google Chat",
137+
"docsPath": "/channels/googlechat",
138+
"docsLabel": "googlechat",
139+
"blurb": "Google Workspace Chat app with HTTP webhook.",
140+
"aliases": ["gchat", "google-chat"],
141+
"order": 55,
142+
"systemImage": "message.badge",
143+
"markdownCapable": true,
144+
"doctorCapabilities": {
145+
"dmAllowFromMode": "nestedOnly",
146+
"groupModel": "route",
147+
"groupAllowFromFallbackToAllowFrom": false,
148+
"warnOnEmptyGroupSenderAllowlist": false
149+
},
150+
"cliAddOptions": [
151+
{
152+
"flags": "--webhook-path <path>",
153+
"description": "Google Chat webhook path"
154+
},
155+
{
156+
"flags": "--webhook-url <url>",
157+
"description": "Google Chat webhook URL"
158+
},
159+
{
160+
"flags": "--audience-type <type>",
161+
"description": "Google Chat audience type (app-url|project-number)"
162+
},
163+
{
164+
"flags": "--audience <value>",
165+
"description": "Google Chat audience value (app URL or project number)"
166+
}
167+
]
168+
},
169+
"install": {
170+
"npmSpec": "@openclaw/googlechat",
171+
"defaultChoice": "npm",
172+
"minHostVersion": ">=2026.4.10"
173+
}
174+
}
175+
},
176+
{
177+
"name": "@openclaw/line",
178+
"description": "OpenClaw LINE channel plugin",
179+
"source": "official",
180+
"kind": "channel",
181+
"openclaw": {
182+
"channel": {
183+
"id": "line",
184+
"label": "LINE",
185+
"selectionLabel": "LINE (Messaging API)",
186+
"detailLabel": "LINE Bot",
187+
"docsPath": "/channels/line",
188+
"docsLabel": "line",
189+
"blurb": "LINE Messaging API webhook bot.",
190+
"systemImage": "message",
191+
"order": 75,
192+
"quickstartAllowFrom": true
193+
},
194+
"install": {
195+
"npmSpec": "@openclaw/line",
196+
"defaultChoice": "npm",
197+
"minHostVersion": ">=2026.4.10"
198+
}
199+
}
200+
},
126201
{
127202
"name": "@openclaw/matrix",
128203
"description": "OpenClaw Matrix channel plugin",

scripts/lib/official-external-plugin-catalog.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
{
22
"entries": [
3+
{
4+
"name": "@openclaw/acpx",
5+
"description": "OpenClaw ACP runtime backend",
6+
"source": "official",
7+
"kind": "plugin",
8+
"openclaw": {
9+
"plugin": {
10+
"id": "acpx",
11+
"label": "ACPX Runtime"
12+
},
13+
"install": {
14+
"npmSpec": "@openclaw/acpx",
15+
"defaultChoice": "npm",
16+
"minHostVersion": ">=2026.4.25"
17+
}
18+
}
19+
},
320
{
421
"name": "@openclaw/brave-plugin",
522
"description": "OpenClaw Brave plugin",
@@ -10,6 +27,21 @@
1027
"id": "brave",
1128
"label": "Brave"
1229
},
30+
"webSearchProviders": [
31+
{
32+
"id": "brave",
33+
"label": "Brave Search",
34+
"hint": "Brave Search web results.",
35+
"onboardingScopes": ["text-inference"],
36+
"credentialLabel": "Brave Search API key",
37+
"envVars": ["BRAVE_API_KEY"],
38+
"placeholder": "BSA...",
39+
"signupUrl": "https://api-dashboard.search.brave.com/app/keys",
40+
"docsUrl": "https://docs.openclaw.ai/tools/brave-search",
41+
"credentialPath": "plugins.entries.brave.config.webSearch.apiKey",
42+
"autoDetectOrder": 10
43+
}
44+
],
1345
"install": {
1446
"npmSpec": "@openclaw/brave-plugin",
1547
"defaultChoice": "npm",

src/commands/doctor/shared/missing-configured-plugin-install.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const mocks = vi.hoisted(() => ({
77
listOfficialExternalPluginCatalogEntries: vi.fn(),
88
loadInstalledPluginIndexInstallRecords: vi.fn(),
99
loadPluginMetadataSnapshot: vi.fn(),
10+
getOfficialExternalPluginCatalogManifest: vi.fn(
11+
(entry: { openclaw?: unknown }) => entry.openclaw,
12+
),
1013
resolveOfficialExternalPluginId: vi.fn((entry: { id?: string }) => entry.id),
1114
resolveOfficialExternalPluginInstall: vi.fn(
1215
(entry: { install?: unknown }) => entry.install ?? null,
@@ -51,6 +54,7 @@ vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({
5154
}));
5255

5356
vi.mock("../../../plugins/official-external-plugin-catalog.js", () => ({
57+
getOfficialExternalPluginCatalogManifest: mocks.getOfficialExternalPluginCatalogManifest,
5458
listOfficialExternalPluginCatalogEntries: mocks.listOfficialExternalPluginCatalogEntries,
5559
resolveOfficialExternalPluginId: mocks.resolveOfficialExternalPluginId,
5660
resolveOfficialExternalPluginInstall: mocks.resolveOfficialExternalPluginInstall,
@@ -522,4 +526,169 @@ describe("repairMissingConfiguredPluginInstalls", () => {
522526
);
523527
expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']);
524528
});
529+
530+
it("reinstalls a recorded external web search plugin from provider-only config", async () => {
531+
const records = {
532+
brave: {
533+
source: "npm",
534+
spec: "@openclaw/brave-plugin@beta",
535+
installPath: "/missing/brave",
536+
},
537+
};
538+
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
539+
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
540+
{
541+
id: "brave",
542+
label: "Brave",
543+
install: {
544+
npmSpec: "@openclaw/brave-plugin",
545+
defaultChoice: "npm",
546+
},
547+
openclaw: {
548+
plugin: { id: "brave", label: "Brave" },
549+
webSearchProviders: [
550+
{
551+
id: "brave",
552+
label: "Brave Search",
553+
hint: "Brave Search",
554+
envVars: ["BRAVE_API_KEY"],
555+
placeholder: "BSA...",
556+
signupUrl: "https://example.test/brave",
557+
},
558+
],
559+
},
560+
},
561+
]);
562+
mocks.updateNpmInstalledPlugins.mockResolvedValue({
563+
changed: true,
564+
config: {
565+
plugins: {
566+
installs: {
567+
brave: {
568+
source: "npm",
569+
spec: "@openclaw/brave-plugin@beta",
570+
installPath: "/tmp/openclaw-plugins/brave",
571+
},
572+
},
573+
},
574+
},
575+
outcomes: [
576+
{
577+
pluginId: "brave",
578+
status: "updated",
579+
message: "Updated brave.",
580+
},
581+
],
582+
});
583+
584+
const { repairMissingConfiguredPluginInstalls } =
585+
await import("./missing-configured-plugin-install.js");
586+
const result = await repairMissingConfiguredPluginInstalls({
587+
cfg: {
588+
tools: {
589+
web: {
590+
search: {
591+
provider: "brave",
592+
},
593+
},
594+
},
595+
},
596+
env: {},
597+
});
598+
599+
expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith(
600+
expect.objectContaining({
601+
pluginIds: ["brave"],
602+
config: expect.objectContaining({
603+
plugins: expect.objectContaining({ installs: records }),
604+
}),
605+
}),
606+
);
607+
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
608+
expect.objectContaining({
609+
brave: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/brave" }),
610+
}),
611+
{ env: {} },
612+
);
613+
expect(result.changes).toEqual(['Repaired missing configured plugin "brave".']);
614+
});
615+
616+
it("installs a configured external web search plugin from provider-only config", async () => {
617+
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
618+
{
619+
id: "brave",
620+
label: "Brave",
621+
install: {
622+
npmSpec: "@openclaw/brave-plugin",
623+
defaultChoice: "npm",
624+
},
625+
openclaw: {
626+
plugin: { id: "brave", label: "Brave" },
627+
webSearchProviders: [
628+
{
629+
id: "brave",
630+
label: "Brave Search",
631+
hint: "Brave Search",
632+
envVars: ["BRAVE_API_KEY"],
633+
placeholder: "BSA...",
634+
signupUrl: "https://example.test/brave",
635+
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
636+
},
637+
],
638+
install: {
639+
npmSpec: "@openclaw/brave-plugin",
640+
defaultChoice: "npm",
641+
},
642+
},
643+
},
644+
]);
645+
mocks.resolveOfficialExternalPluginId.mockImplementation(
646+
(entry: { id?: string; openclaw?: { plugin?: { id?: string } } }) =>
647+
entry.openclaw?.plugin?.id ?? entry.id,
648+
);
649+
mocks.resolveOfficialExternalPluginInstall.mockImplementation(
650+
(entry: { install?: unknown; openclaw?: { install?: unknown } }) =>
651+
entry.openclaw?.install ?? entry.install ?? null,
652+
);
653+
mocks.resolveOfficialExternalPluginLabel.mockImplementation(
654+
(entry: { label?: string; openclaw?: { plugin?: { label?: string } } }) =>
655+
entry.openclaw?.plugin?.label ?? entry.label ?? "plugin",
656+
);
657+
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
658+
ok: true,
659+
pluginId: "brave",
660+
targetDir: "/tmp/openclaw-plugins/brave",
661+
version: "2026.5.2",
662+
npmResolution: {
663+
name: "@openclaw/brave-plugin",
664+
version: "2026.5.2",
665+
resolvedSpec: "@openclaw/brave-plugin@2026.5.2",
666+
},
667+
});
668+
669+
const { repairMissingConfiguredPluginInstalls } =
670+
await import("./missing-configured-plugin-install.js");
671+
const result = await repairMissingConfiguredPluginInstalls({
672+
cfg: {
673+
tools: {
674+
web: {
675+
search: {
676+
provider: "brave",
677+
},
678+
},
679+
},
680+
},
681+
env: {},
682+
});
683+
684+
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
685+
expect.objectContaining({
686+
spec: "@openclaw/brave-plugin",
687+
expectedPluginId: "brave",
688+
}),
689+
);
690+
expect(result.changes).toEqual([
691+
'Installed missing configured plugin "brave" from @openclaw/brave-plugin.',
692+
]);
693+
});
525694
});

src/commands/doctor/shared/missing-configured-plugin-install.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "../../../plugins/official-external-plugin-catalog.js";
2020
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
2121
import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
22+
import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js";
2223
import { asObjectRecord } from "./object.js";
2324

2425
type DownloadableInstallCandidate = {
@@ -31,6 +32,11 @@ type DownloadableInstallCandidate = {
3132
};
3233

3334
const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[] = [
35+
{
36+
pluginId: "acpx",
37+
label: "ACPX Runtime",
38+
npmSpec: "@openclaw/acpx",
39+
},
3440
// Runtime-only configs do not have a provider/channel integration catalog entry.
3541
{
3642
pluginId: "codex",
@@ -87,6 +93,23 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig): Set<string> {
8793
ids.add(pluginId.trim());
8894
}
8995
}
96+
const searchProvider = cfg.tools?.web?.search?.provider;
97+
if (typeof searchProvider === "string") {
98+
const installEntry = resolveWebSearchInstallCatalogEntry({ providerId: searchProvider });
99+
if (installEntry?.pluginId) {
100+
ids.add(installEntry.pluginId);
101+
}
102+
}
103+
const acp = asObjectRecord(cfg.acp);
104+
const acpBackend = typeof acp?.backend === "string" ? acp.backend.trim().toLowerCase() : "";
105+
if (
106+
(acpBackend === "acpx" ||
107+
acp?.enabled === true ||
108+
asObjectRecord(acp?.dispatch)?.enabled === true) &&
109+
(!acpBackend || acpBackend === "acpx")
110+
) {
111+
ids.add("acpx");
112+
}
90113
return ids;
91114
}
92115

src/commands/doctor/shared/release-configured-plugin-installs.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,30 @@ describe("configured plugin install release step", () => {
160160
expect(result.channelIds).toEqual([]);
161161
});
162162

163+
it("collects external web search and ACP runtime plugins from config-only usage", async () => {
164+
const { collectReleaseConfiguredPluginIds } =
165+
await import("./release-configured-plugin-installs.js");
166+
const result = collectReleaseConfiguredPluginIds({
167+
cfg: {
168+
acp: {
169+
enabled: true,
170+
backend: "acpx",
171+
},
172+
tools: {
173+
web: {
174+
search: {
175+
provider: "brave",
176+
},
177+
},
178+
},
179+
},
180+
env: {},
181+
});
182+
183+
expect(result.pluginIds).toEqual(["acpx", "brave"]);
184+
expect(result.channelIds).toEqual([]);
185+
});
186+
163187
it("does not collect channel ids when the matching plugin id is blocked", async () => {
164188
const { collectReleaseConfiguredPluginIds } =
165189
await import("./release-configured-plugin-installs.js");

0 commit comments

Comments
 (0)