fix(doctor): repair effective plugin installs

This commit is contained in:
Vincent Koc
2026-05-02 14:36:07 -07:00
parent c5013eaf43
commit 5d6445417f
3 changed files with 412 additions and 59 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/externalization: repair missing configured plugin installs from npm by default, reserve ClawHub downloads for explicit `clawhubSpec` metadata, and cover agent-runtime/env-selected plugin repair. Thanks @vincentkoc.
- Plugins/externalization: keep ACPX, Google Chat, and LINE publishable plugin dist trees out of the core npm package file list.
- Plugins/ClawHub: fall back to version metadata when the artifact resolver route is missing and keep the Docker ClawHub fixture aligned with npm-pack artifact resolution, avoiding false version-not-found failures during plugin install validation. Thanks @vincentkoc.
- Status/channels: show configured channels in `openclaw status` and config-only `openclaw channels status` output even when the Gateway is unreachable, avoiding empty Channels tables on WSL and other no-Gateway paths. Thanks @vincentkoc.

View File

@@ -115,7 +115,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
});
it("installs a missing configured OpenClaw channel plugin from ClawHub", async () => {
it("installs a missing configured OpenClaw channel plugin from npm by default", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "matrix",
@@ -133,33 +133,33 @@ describe("repairMissingConfiguredPluginInstalls", () => {
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
channels: {
matrix: { enabled: true },
matrix: { enabled: true, homeserver: "https://matrix.example.org" },
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).toHaveBeenCalledWith(
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:@openclaw/plugin-matrix@1.2.3",
spec: "@openclaw/plugin-matrix@1.2.3",
extensionsDir: "/tmp/openclaw-plugins",
expectedPluginId: "matrix",
expectedIntegrity: "sha512-test",
}),
);
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
expect.objectContaining({
matrix: expect.objectContaining({
source: "clawhub",
spec: "clawhub:@openclaw/plugin-matrix@1.2.3",
clawhubPackage: "@openclaw/plugin-matrix",
source: "npm",
spec: "@openclaw/plugin-matrix@1.2.3",
installPath: "/tmp/openclaw-plugins/matrix",
}),
}),
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "matrix" from clawhub:@openclaw/plugin-matrix@1.2.3.',
'Installed missing configured plugin "matrix" from @openclaw/plugin-matrix@1.2.3.',
]);
expect(result.warnings).toEqual([]);
});
@@ -183,7 +183,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
channels: {
matrix: { enabled: true },
matrix: { enabled: true, homeserver: "https://matrix.example.org" },
},
},
env: {},
@@ -202,6 +202,62 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result.warnings).toEqual([]);
});
it("installs a missing channel plugin selected by environment config from npm", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "matrix",
targetDir: "/tmp/openclaw-plugins/matrix",
version: "1.2.3",
npmResolution: {
name: "@openclaw/plugin-matrix",
version: "1.2.3",
resolvedSpec: "@openclaw/plugin-matrix@1.2.3",
integrity: "sha512-matrix",
resolvedAt: "2026-05-01T00:00:00.000Z",
},
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "matrix",
pluginId: "matrix",
meta: { label: "Matrix" },
install: {
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {},
env: { MATRIX_HOMESERVER: "https://matrix.example.org" },
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/plugin-matrix@1.2.3",
extensionsDir: "/tmp/openclaw-plugins",
expectedPluginId: "matrix",
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
expect.objectContaining({
matrix: expect.objectContaining({
source: "npm",
spec: "@openclaw/plugin-matrix@1.2.3",
installPath: "/tmp/openclaw-plugins/matrix",
}),
}),
{ env: { MATRIX_HOMESERVER: "https://matrix.example.org" } },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "matrix" from @openclaw/plugin-matrix@1.2.3.',
]);
expect(result.warnings).toEqual([]);
});
it("falls back to npm when an OpenClaw channel plugin is not on ClawHub", async () => {
mocks.installPluginFromClawHub.mockResolvedValueOnce({
ok: false,
@@ -214,6 +270,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
pluginId: "matrix",
meta: { label: "Matrix" },
install: {
clawhubSpec: "clawhub:@openclaw/plugin-matrix@stable",
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
},
@@ -235,7 +292,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
}),
);
expect(result.changes).toEqual([
'ClawHub clawhub:@openclaw/plugin-matrix@1.2.3 unavailable for "matrix"; falling back to npm @openclaw/plugin-matrix@1.2.3.',
'ClawHub clawhub:@openclaw/plugin-matrix@stable unavailable for "matrix"; falling back to npm @openclaw/plugin-matrix@1.2.3.',
'Installed missing configured plugin "matrix" from @openclaw/plugin-matrix@1.2.3.',
]);
expect(result.warnings).toEqual([]);
@@ -339,6 +396,126 @@ describe("repairMissingConfiguredPluginInstalls", () => {
]);
});
it("does not install disabled configured plugin entries", async () => {
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "diagnostics-otel",
label: "Diagnostics OpenTelemetry",
install: {
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
"diagnostics-otel": { enabled: false },
},
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [] });
});
it.each([
["enabled-only disabled stub", { channels: { matrix: { enabled: false } } }],
[
"disabled configured channel",
{ channels: { matrix: { enabled: false, homeserver: "https://matrix.example.org" } } },
],
])("does not install channel plugins for a %s", async (_label, cfg) => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "matrix",
pluginId: "matrix",
meta: { label: "Matrix" },
install: {
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg,
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [] });
});
it("does not install configured plugins when plugins are globally disabled", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "matrix",
pluginId: "matrix",
meta: { label: "Matrix" },
install: {
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
},
]);
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "codex",
label: "Codex",
install: {
npmSpec: "@openclaw/codex",
defaultChoice: "npm",
},
},
{
id: "diagnostics-otel",
label: "Diagnostics OpenTelemetry",
install: {
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
enabled: false,
entries: {
"diagnostics-otel": { enabled: true },
},
},
channels: {
matrix: { homeserver: "https://matrix.example.org" },
},
agents: {
defaults: {
agentRuntime: { id: "codex" },
},
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [] });
});
it("installs a missing third-party downloadable plugin from npm only", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
@@ -385,20 +562,30 @@ describe("repairMissingConfiguredPluginInstalls", () => {
]);
});
it("installs the missing configured Codex runtime plugin from the beta npm tag", async () => {
it("installs a missing default Codex runtime plugin from the official external catalog", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "codex",
targetDir: "/tmp/openclaw-plugins/codex",
version: "2026.5.2-beta.1",
version: "2026.5.2",
npmResolution: {
name: "@openclaw/codex",
version: "2026.5.2-beta.1",
resolvedSpec: "@openclaw/codex@2026.5.2-beta.1",
integrity: "sha512-codex-beta",
version: "2026.5.2",
resolvedSpec: "@openclaw/codex@2026.5.2",
integrity: "sha512-codex",
resolvedAt: "2026-05-01T00:00:00.000Z",
},
});
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "codex",
label: "Codex",
install: {
npmSpec: "@openclaw/codex",
defaultChoice: "npm",
},
},
]);
const { repairMissingPluginInstallsForIds } =
await import("./missing-configured-plugin-install.js");
@@ -418,7 +605,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.resolveProviderInstallCatalogEntries).toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex@beta",
spec: "@openclaw/codex",
expectedPluginId: "codex",
}),
);
@@ -426,19 +613,96 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
codex: expect.objectContaining({
source: "npm",
spec: "@openclaw/codex@beta",
spec: "@openclaw/codex",
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2-beta.1",
version: "2026.5.2",
}),
}),
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "codex" from @openclaw/codex@beta.',
'Installed missing configured plugin "codex" from @openclaw/codex.',
]);
expect(result.warnings).toEqual([]);
});
it.each([
[
"default agent runtime",
{
agents: {
defaults: {
agentRuntime: { id: "codex" },
},
},
},
{},
],
[
"agent runtime override",
{
agents: {
list: [{ id: "main", agentRuntime: { id: "codex" } }],
},
},
{},
],
["environment runtime override", {}, { OPENCLAW_AGENT_RUNTIME: "codex" }],
])("repairs a missing Codex plugin selected by %s", async (_label, cfg, env) => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "codex",
targetDir: "/tmp/openclaw-plugins/codex",
version: "2026.5.2",
npmResolution: {
name: "@openclaw/codex",
version: "2026.5.2",
resolvedSpec: "@openclaw/codex@2026.5.2",
integrity: "sha512-codex",
resolvedAt: "2026-05-01T00:00:00.000Z",
},
});
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "codex",
label: "Codex",
install: {
npmSpec: "@openclaw/codex",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg,
env,
});
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex",
expectedPluginId: "codex",
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
expect.objectContaining({
codex: expect.objectContaining({
source: "npm",
spec: "@openclaw/codex",
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2",
}),
}),
{ env },
);
expect(result).toEqual({
changes: ['Installed missing configured plugin "codex" from @openclaw/codex.'],
warnings: [],
});
});
it("does not install a blocked downloadable plugin from explicit channel ids", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
@@ -691,4 +955,68 @@ describe("repairMissingConfiguredPluginInstalls", () => {
'Installed missing configured plugin "brave" from @openclaw/brave-plugin.',
]);
});
it("does not install a configured external web search plugin when search is disabled", 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",
);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
tools: {
web: {
search: {
enabled: false,
provider: "brave",
},
},
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [] });
});
});

View File

@@ -1,7 +1,10 @@
import {
listExplicitlyDisabledChannelIdsForConfig,
listPotentialConfiguredChannelIds,
} from "../../../channels/config-presence.js";
import { listChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
import { parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js";
import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js";
import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js";
@@ -41,18 +44,10 @@ const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[]
{
pluginId: "codex",
label: "Codex",
npmSpec: "@openclaw/codex@beta",
npmSpec: "@openclaw/codex",
},
];
function buildOpenClawClawHubSpec(npmSpec: string): string | undefined {
const parsed = parseRegistryNpmSpec(npmSpec);
if (!parsed?.name.startsWith("@openclaw/")) {
return undefined;
}
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
}
function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boolean {
return (
result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
@@ -60,41 +55,58 @@ function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boole
);
}
function normalizeInstallDefaultChoice(
value: PluginPackageInstall["defaultChoice"] | undefined,
): PluginPackageInstall["defaultChoice"] | undefined {
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
}
function resolveCandidateClawHubSpec(install: PluginPackageInstall): string | undefined {
const explicit = install.clawhubSpec?.trim();
if (explicit) {
return explicit;
}
const npmSpec = install.npmSpec?.trim();
if (!npmSpec || normalizeInstallDefaultChoice(install.defaultChoice) === "npm") {
return undefined;
}
return buildOpenClawClawHubSpec(npmSpec);
return undefined;
}
function collectConfiguredPluginIds(cfg: OpenClawConfig): Set<string> {
function addConfiguredPluginId(ids: Set<string>, value: unknown): void {
if (typeof value !== "string") {
return;
}
const pluginId = value.trim();
if (pluginId) {
ids.add(pluginId);
}
}
function addConfiguredAgentRuntimePluginIds(
ids: Set<string>,
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): void {
addConfiguredPluginId(ids, env?.OPENCLAW_AGENT_RUNTIME);
const agents = asObjectRecord(cfg.agents);
const defaults = asObjectRecord(agents?.defaults);
addConfiguredPluginId(ids, asObjectRecord(defaults?.agentRuntime)?.id);
const list = Array.isArray(agents?.list) ? agents.list : [];
for (const entry of list) {
addConfiguredPluginId(ids, asObjectRecord(asObjectRecord(entry)?.agentRuntime)?.id);
}
}
function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): Set<string> {
const ids = new Set<string>();
const plugins = asObjectRecord(cfg.plugins);
if (plugins?.enabled === false) {
return ids;
}
const allow = Array.isArray(plugins?.allow) ? plugins.allow : [];
for (const value of allow) {
if (typeof value === "string" && value.trim()) {
ids.add(value.trim());
}
addConfiguredPluginId(ids, value);
}
const entries = asObjectRecord(plugins?.entries);
for (const pluginId of Object.keys(entries ?? {})) {
if (pluginId.trim()) {
ids.add(pluginId.trim());
for (const [pluginId, entry] of Object.entries(entries ?? {})) {
if (asObjectRecord(entry)?.enabled === false) {
continue;
}
addConfiguredPluginId(ids, pluginId);
}
const searchProvider = cfg.tools?.web?.search?.provider;
if (typeof searchProvider === "string") {
if (cfg.tools?.web?.search?.enabled !== false && typeof searchProvider === "string") {
const installEntry = resolveWebSearchInstallCatalogEntry({ providerId: searchProvider });
if (installEntry?.pluginId) {
ids.add(installEntry.pluginId);
@@ -110,15 +122,27 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig): Set<string> {
) {
ids.add("acpx");
}
addConfiguredAgentRuntimePluginIds(ids, cfg, env);
return ids;
}
function collectConfiguredChannelIds(cfg: OpenClawConfig): Set<string> {
function collectConfiguredChannelIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): Set<string> {
const ids = new Set<string>();
const channels = asObjectRecord(cfg.channels);
for (const channelId of Object.keys(channels ?? {})) {
if (channelId !== "defaults" && channelId.trim()) {
ids.add(channelId.trim());
if (asObjectRecord(cfg.plugins)?.enabled === false) {
return ids;
}
const disabled = new Set(listExplicitlyDisabledChannelIdsForConfig(cfg));
const candidateChannelIds = listChannelPluginCatalogEntries({
env,
excludeWorkspace: true,
}).map((entry) => entry.id);
for (const channelId of listPotentialConfiguredChannelIds(cfg, env, {
channelIds: candidateChannelIds,
includePersistedAuthState: false,
})) {
const normalized = channelId.trim();
if (normalized && !disabled.has(normalized.toLowerCase())) {
ids.add(normalized);
}
}
return ids;
@@ -132,9 +156,10 @@ function collectDownloadableInstallCandidates(params: {
configuredChannelIds?: ReadonlySet<string>;
blockedPluginIds?: ReadonlySet<string>;
}): DownloadableInstallCandidate[] {
const configuredPluginIds = params.configuredPluginIds ?? collectConfiguredPluginIds(params.cfg);
const configuredPluginIds =
params.configuredPluginIds ?? collectConfiguredPluginIds(params.cfg, params.env);
const configuredChannelIds =
params.configuredChannelIds ?? collectConfiguredChannelIds(params.cfg);
params.configuredChannelIds ?? collectConfiguredChannelIds(params.cfg, params.env);
const candidates = new Map<string, DownloadableInstallCandidate>();
for (const entry of listChannelPluginCatalogEntries({
@@ -341,8 +366,8 @@ export async function repairMissingConfiguredPluginInstalls(params: {
return repairMissingPluginInstalls({
cfg: params.cfg,
env: params.env,
pluginIds: collectConfiguredPluginIds(params.cfg),
channelIds: collectConfiguredChannelIds(params.cfg),
pluginIds: collectConfiguredPluginIds(params.cfg, params.env),
channelIds: collectConfiguredChannelIds(params.cfg, params.env),
});
}
@@ -451,5 +476,4 @@ export const __testing = {
collectConfiguredChannelIds,
collectConfiguredPluginIds,
collectDownloadableInstallCandidates,
buildOpenClawClawHubSpec,
};