Skip to content

Commit b0f841e

Browse files
committed
fix(plugins): honor beta channel for auto installs
1 parent e03fe1e commit b0f841e

7 files changed

Lines changed: 325 additions & 90 deletions

File tree

CHANGELOG.md

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

6464
### Fixes
6565

66+
- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc.
6667
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
6768
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.
6869
- Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91.

src/commands/channel-setup/plugin-install.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,54 @@ describe("ensureChannelSetupPluginInstalled", () => {
437437
expect(await runInitialValueForChannel("beta")).toBe("npm");
438438
});
439439

440+
it("installs npm beta on the beta channel without persisting the beta tag", async () => {
441+
const runtime = makeRuntime();
442+
const { prompter, select } = makeSkipInstallPrompter();
443+
const cfg: OpenClawConfig = { update: { channel: "beta" } };
444+
vi.mocked(fs.existsSync).mockReturnValue(false);
445+
installPluginFromNpmSpec.mockResolvedValue({
446+
ok: true,
447+
pluginId: "wecom-openclaw-plugin",
448+
targetDir: "/tmp/wecom-openclaw-plugin",
449+
version: "2026.5.4-beta.1",
450+
npmResolution: {
451+
name: "@openclaw/wecom",
452+
version: "2026.5.4-beta.1",
453+
resolvedSpec: "@openclaw/wecom@2026.5.4-beta.1",
454+
},
455+
});
456+
457+
const result = await ensureChannelSetupPluginInstalled({
458+
cfg,
459+
entry: {
460+
id: "wecom",
461+
pluginId: "wecom-openclaw-plugin",
462+
meta: {
463+
id: "wecom",
464+
label: "WeCom",
465+
selectionLabel: "WeCom",
466+
docsPath: "/channels/wecom",
467+
blurb: "WeCom channel",
468+
},
469+
install: {
470+
npmSpec: "@openclaw/wecom",
471+
},
472+
},
473+
prompter,
474+
runtime,
475+
promptInstall: false,
476+
});
477+
478+
expect(select).not.toHaveBeenCalled();
479+
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
480+
expect.objectContaining({
481+
spec: "@openclaw/wecom@beta",
482+
expectedPluginId: "wecom-openclaw-plugin",
483+
}),
484+
);
485+
expect(result.cfg.plugins?.installs?.["wecom-openclaw-plugin"]?.spec).toBe("@openclaw/wecom");
486+
});
487+
440488
it("defaults to bundled local path on beta channel when available", async () => {
441489
const runtime = makeRuntime();
442490
const { prompter, select } = makeSkipInstallPrompter();

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,93 @@ describe("repairMissingConfiguredPluginInstalls", () => {
20152015
]);
20162016
});
20172017

2018+
it("installs configured external web search plugins from beta on the beta channel", async () => {
2019+
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
2020+
{
2021+
id: "brave",
2022+
label: "Brave",
2023+
install: {
2024+
npmSpec: "@openclaw/brave-plugin",
2025+
defaultChoice: "npm",
2026+
},
2027+
openclaw: {
2028+
plugin: { id: "brave", label: "Brave" },
2029+
webSearchProviders: [
2030+
{
2031+
id: "brave",
2032+
label: "Brave Search",
2033+
hint: "Brave Search",
2034+
envVars: ["BRAVE_API_KEY"],
2035+
placeholder: "BSA...",
2036+
signupUrl: "https://example.test/brave",
2037+
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
2038+
},
2039+
],
2040+
install: {
2041+
npmSpec: "@openclaw/brave-plugin",
2042+
defaultChoice: "npm",
2043+
},
2044+
},
2045+
},
2046+
]);
2047+
mocks.resolveOfficialExternalPluginId.mockImplementation(
2048+
(entry: { id?: string; openclaw?: { plugin?: { id?: string } } }) =>
2049+
entry.openclaw?.plugin?.id ?? entry.id,
2050+
);
2051+
mocks.resolveOfficialExternalPluginInstall.mockImplementation(
2052+
(entry: { install?: unknown; openclaw?: { install?: unknown } }) =>
2053+
entry.openclaw?.install ?? entry.install ?? null,
2054+
);
2055+
mocks.resolveOfficialExternalPluginLabel.mockImplementation(
2056+
(entry: { label?: string; openclaw?: { plugin?: { label?: string } } }) =>
2057+
entry.openclaw?.plugin?.label ?? entry.label ?? "plugin",
2058+
);
2059+
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
2060+
ok: true,
2061+
pluginId: "brave",
2062+
targetDir: "/tmp/openclaw-plugins/brave",
2063+
version: "2026.5.4-beta.1",
2064+
npmResolution: {
2065+
name: "@openclaw/brave-plugin",
2066+
version: "2026.5.4-beta.1",
2067+
resolvedSpec: "@openclaw/brave-plugin@2026.5.4-beta.1",
2068+
},
2069+
});
2070+
2071+
const { repairMissingConfiguredPluginInstalls } =
2072+
await import("./missing-configured-plugin-install.js");
2073+
const result = await repairMissingConfiguredPluginInstalls({
2074+
cfg: {
2075+
update: { channel: "beta" },
2076+
tools: {
2077+
web: {
2078+
search: {
2079+
provider: "brave",
2080+
},
2081+
},
2082+
},
2083+
},
2084+
env: {},
2085+
});
2086+
2087+
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
2088+
expect.objectContaining({
2089+
spec: "@openclaw/brave-plugin@beta",
2090+
expectedPluginId: "brave",
2091+
trustedSourceLinkedOfficialInstall: true,
2092+
}),
2093+
);
2094+
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
2095+
expect.objectContaining({
2096+
brave: expect.objectContaining({ spec: "@openclaw/brave-plugin" }),
2097+
}),
2098+
{ env: {} },
2099+
);
2100+
expect(result.changes).toEqual([
2101+
'Installed missing configured plugin "brave" from @openclaw/brave-plugin@beta.',
2102+
]);
2103+
});
2104+
20182105
it("does not install a configured external web search plugin when search is disabled", async () => {
20192106
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
20202107
{

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

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,18 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
99
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
1010
import { parseClawHubPluginSpec } from "../../../infra/clawhub-spec.js";
1111
import { parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js";
12+
import {
13+
normalizeUpdateChannel,
14+
resolveRegistryUpdateChannel,
15+
type UpdateChannel,
16+
} from "../../../infra/update-channels.js";
1217
import { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.js";
1318
import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js";
1419
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js";
20+
import {
21+
resolveClawHubInstallSpecsForUpdateChannel,
22+
resolveNpmInstallSpecsForUpdateChannel,
23+
} from "../../../plugins/install-channel-specs.js";
1524
import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js";
1625
import { installPluginFromNpmSpec } from "../../../plugins/install.js";
1726
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
@@ -32,6 +41,7 @@ import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
3241
import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js";
3342
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
3443
import { resolveUserPath } from "../../../utils.js";
44+
import { VERSION } from "../../../version.js";
3545
import { asObjectRecord } from "./object.js";
3646

3747
type DownloadableInstallCandidate = {
@@ -457,6 +467,7 @@ function recordClawHubPackageName(value: string | undefined): string | undefined
457467
async function installCandidate(params: {
458468
candidate: DownloadableInstallCandidate;
459469
records: Record<string, PluginInstallRecord>;
470+
updateChannel?: UpdateChannel;
460471
}): Promise<{
461472
records: Record<string, PluginInstallRecord>;
462473
changes: string[];
@@ -465,9 +476,23 @@ async function installCandidate(params: {
465476
const { candidate } = params;
466477
const extensionsDir = resolveDefaultPluginExtensionsDir();
467478
const changes: string[] = [];
468-
if (candidate.clawhubSpec && candidate.defaultChoice !== "npm") {
479+
const clawhubSpecs = candidate.clawhubSpec
480+
? resolveClawHubInstallSpecsForUpdateChannel({
481+
spec: candidate.clawhubSpec,
482+
updateChannel: params.updateChannel,
483+
})
484+
: null;
485+
const npmSpecs = candidate.npmSpec
486+
? resolveNpmInstallSpecsForUpdateChannel({
487+
spec: candidate.npmSpec,
488+
updateChannel: params.updateChannel,
489+
})
490+
: null;
491+
const clawhubInstallSpec = clawhubSpecs?.installSpec ?? candidate.clawhubSpec;
492+
const npmInstallSpec = npmSpecs?.installSpec ?? candidate.npmSpec;
493+
if (clawhubInstallSpec && candidate.defaultChoice !== "npm") {
469494
const clawhubResult = await installPluginFromClawHub({
470-
spec: candidate.clawhubSpec,
495+
spec: clawhubInstallSpec,
471496
extensionsDir,
472497
expectedPluginId: candidate.pluginId,
473498
mode: "install",
@@ -479,31 +504,29 @@ async function installCandidate(params: {
479504
...params.records,
480505
[pluginId]: {
481506
...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub),
482-
spec: candidate.clawhubSpec,
507+
spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec,
483508
installPath: clawhubResult.targetDir,
484509
installedAt: new Date().toISOString(),
485510
},
486511
},
487-
changes: [
488-
`Installed missing configured plugin "${pluginId}" from ${candidate.clawhubSpec}.`,
489-
],
512+
changes: [`Installed missing configured plugin "${pluginId}" from ${clawhubInstallSpec}.`],
490513
warnings: [],
491514
};
492515
}
493-
if (!candidate.npmSpec || !shouldFallbackClawHubToNpm(clawhubResult)) {
516+
if (!npmInstallSpec || !shouldFallbackClawHubToNpm(clawhubResult)) {
494517
return {
495518
records: params.records,
496519
changes: [],
497520
warnings: [
498-
`Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.clawhubSpec}: ${clawhubResult.error}`,
521+
`Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`,
499522
],
500523
};
501524
}
502525
changes.push(
503-
`ClawHub ${candidate.clawhubSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${candidate.npmSpec}.`,
526+
`ClawHub ${clawhubInstallSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${npmInstallSpec}.`,
504527
);
505528
}
506-
if (!candidate.npmSpec) {
529+
if (!npmInstallSpec) {
507530
return {
508531
records: params.records,
509532
changes: [],
@@ -513,7 +536,7 @@ async function installCandidate(params: {
513536
};
514537
}
515538
const result = await installPluginFromNpmSpec({
516-
spec: candidate.npmSpec,
539+
spec: npmInstallSpec,
517540
extensionsDir,
518541
expectedPluginId: candidate.pluginId,
519542
expectedIntegrity: candidate.expectedIntegrity,
@@ -527,7 +550,7 @@ async function installCandidate(params: {
527550
records: params.records,
528551
changes: [],
529552
warnings: [
530-
`Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.npmSpec}: ${result.error}`,
553+
`Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`,
531554
],
532555
};
533556
}
@@ -537,7 +560,7 @@ async function installCandidate(params: {
537560
...params.records,
538561
[pluginId]: {
539562
source: "npm",
540-
spec: candidate.npmSpec,
563+
spec: npmSpecs?.recordSpec ?? npmInstallSpec,
541564
installPath: result.targetDir,
542565
version: result.version,
543566
installedAt: new Date().toISOString(),
@@ -546,7 +569,7 @@ async function installCandidate(params: {
546569
},
547570
changes: [
548571
...changes,
549-
`Installed missing configured plugin "${pluginId}" from ${candidate.npmSpec}.`,
572+
`Installed missing configured plugin "${pluginId}" from ${npmInstallSpec}.`,
550573
],
551574
warnings: [],
552575
};
@@ -642,6 +665,10 @@ async function repairMissingPluginInstalls(params: {
642665
const changes: string[] = [];
643666
const warnings: string[] = [];
644667
const deferredPluginIds = new Set<string>();
668+
const updateChannel = resolveRegistryUpdateChannel({
669+
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
670+
currentVersion: VERSION,
671+
});
645672
let nextRecords = records;
646673

647674
for (const [pluginId, record] of Object.entries(records)) {
@@ -700,7 +727,7 @@ async function repairMissingPluginInstalls(params: {
700727
},
701728
},
702729
pluginIds: missingRecordedPluginIds,
703-
updateChannel: params.cfg.update?.channel,
730+
updateChannel,
704731
logger: {
705732
warn: (message) => warnings.push(message),
706733
error: (message) => warnings.push(message),
@@ -754,7 +781,7 @@ async function repairMissingPluginInstalls(params: {
754781
if (hasUsableRecord) {
755782
continue;
756783
}
757-
const installed = await installCandidate({ candidate, records: nextRecords });
784+
const installed = await installCandidate({ candidate, records: nextRecords, updateChannel });
758785
nextRecords = installed.records;
759786
changes.push(...installed.changes);
760787
warnings.push(...installed.warnings);

0 commit comments

Comments
 (0)