fix(plugins): fallback bundled channel specs when npm install returns 404 (#12849)

* plugins: add bundled source resolver

* plugins: add bundled source resolver tests

* cli: fallback npm 404 plugin installs to bundled sources

* plugins: use bundled source resolver during updates

* protocol: regenerate macos gateway swift models

* protocol: regenerate shared swift models

* Revert "protocol: regenerate shared swift models"

This reverts commit 6a2b08c47d.

* Revert "protocol: regenerate macos gateway swift models"

This reverts commit 27c03010c6.
This commit is contained in:
Vincent Koc
2026-02-26 08:06:54 -05:00
committed by GitHub
parent 7b5153f214
commit cf311978ea
4 changed files with 214 additions and 44 deletions

View File

@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { findBundledPluginByNpmSpec } from "../plugins/bundled-sources.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
@@ -147,6 +148,16 @@ function logSlotWarnings(warnings: string[]) {
}
}
function isPackageNotFoundInstallError(message: string): boolean {
const lower = message.toLowerCase();
return (
lower.includes("npm pack failed:") &&
(lower.includes("e404") ||
lower.includes("404 not found") ||
lower.includes("could not be found"))
);
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -614,8 +625,52 @@ export function registerPluginsCli(program: Command) {
logger: createPluginInstallLogger(),
});
if (!result.ok) {
defaultRuntime.error(result.error);
process.exit(1);
const bundledFallback = isPackageNotFoundInstallError(result.error)
? findBundledPluginByNpmSpec({ spec: raw })
: undefined;
if (!bundledFallback) {
defaultRuntime.error(result.error);
process.exit(1);
}
const existing = cfg.plugins?.load?.paths ?? [];
const mergedPaths = Array.from(new Set([...existing, bundledFallback.localPath]));
let next: OpenClawConfig = {
...cfg,
plugins: {
...cfg.plugins,
load: {
...cfg.plugins?.load,
paths: mergedPaths,
},
entries: {
...cfg.plugins?.entries,
[bundledFallback.pluginId]: {
...(cfg.plugins?.entries?.[bundledFallback.pluginId] as object | undefined),
enabled: true,
},
},
},
};
next = recordPluginInstall(next, {
pluginId: bundledFallback.pluginId,
source: "path",
spec: raw,
sourcePath: bundledFallback.localPath,
installPath: bundledFallback.localPath,
});
const slotResult = applySlotSelectionForPlugin(next, bundledFallback.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(
theme.warn(
`npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
),
);
defaultRuntime.log(`Installed plugin: ${bundledFallback.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
}
// Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup.
clearPluginManifestRegistryCache();

View File

@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { findBundledPluginByNpmSpec, resolveBundledPluginSources } from "./bundled-sources.js";
const discoverOpenClawPluginsMock = vi.fn();
const loadPluginManifestMock = vi.fn();
vi.mock("./discovery.js", () => ({
discoverOpenClawPlugins: (...args: unknown[]) => discoverOpenClawPluginsMock(...args),
}));
vi.mock("./manifest.js", () => ({
loadPluginManifest: (...args: unknown[]) => loadPluginManifestMock(...args),
}));
describe("bundled plugin sources", () => {
beforeEach(() => {
discoverOpenClawPluginsMock.mockReset();
loadPluginManifestMock.mockReset();
});
it("resolves bundled sources keyed by plugin id", () => {
discoverOpenClawPluginsMock.mockReturnValue({
candidates: [
{
origin: "global",
rootDir: "/global/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
rootDir: "/app/extensions/feishu-dup",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
rootDir: "/app/extensions/msteams",
packageName: "@openclaw/msteams",
packageManifest: { install: { npmSpec: "@openclaw/msteams" } },
},
],
diagnostics: [],
});
loadPluginManifestMock.mockImplementation((rootDir: string) => {
if (rootDir === "/app/extensions/feishu") {
return { ok: true, manifest: { id: "feishu" } };
}
if (rootDir === "/app/extensions/msteams") {
return { ok: true, manifest: { id: "msteams" } };
}
return {
ok: false,
error: "invalid manifest",
manifestPath: `${rootDir}/openclaw.plugin.json`,
};
});
const map = resolveBundledPluginSources({});
expect(Array.from(map.keys())).toEqual(["feishu", "msteams"]);
expect(map.get("feishu")).toEqual({
pluginId: "feishu",
localPath: "/app/extensions/feishu",
npmSpec: "@openclaw/feishu",
});
});
it("finds bundled source by npm spec", () => {
discoverOpenClawPluginsMock.mockReturnValue({
candidates: [
{
origin: "bundled",
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
],
diagnostics: [],
});
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } });
const resolved = findBundledPluginByNpmSpec({ spec: "@openclaw/feishu" });
const missing = findBundledPluginByNpmSpec({ spec: "@openclaw/not-found" });
expect(resolved?.pluginId).toBe("feishu");
expect(resolved?.localPath).toBe("/app/extensions/feishu");
expect(missing).toBeUndefined();
});
});

View File

@@ -0,0 +1,59 @@
import { discoverOpenClawPlugins } from "./discovery.js";
import { loadPluginManifest } from "./manifest.js";
export type BundledPluginSource = {
pluginId: string;
localPath: string;
npmSpec?: string;
};
export function resolveBundledPluginSources(params: {
workspaceDir?: string;
}): Map<string, BundledPluginSource> {
const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir });
const bundled = new Map<string, BundledPluginSource>();
for (const candidate of discovery.candidates) {
if (candidate.origin !== "bundled") {
continue;
}
const manifest = loadPluginManifest(candidate.rootDir);
if (!manifest.ok) {
continue;
}
const pluginId = manifest.manifest.id;
if (bundled.has(pluginId)) {
continue;
}
const npmSpec =
candidate.packageManifest?.install?.npmSpec?.trim() ||
candidate.packageName?.trim() ||
undefined;
bundled.set(pluginId, {
pluginId,
localPath: candidate.rootDir,
npmSpec,
});
}
return bundled;
}
export function findBundledPluginByNpmSpec(params: {
spec: string;
workspaceDir?: string;
}): BundledPluginSource | undefined {
const targetSpec = params.spec.trim();
if (!targetSpec) {
return undefined;
}
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
for (const source of bundled.values()) {
if (source.npmSpec === targetSpec) {
return source;
}
}
return undefined;
}

View File

@@ -4,10 +4,9 @@ import type { OpenClawConfig } from "../config/config.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import type { UpdateChannel } from "../infra/update-channels.js";
import { resolveUserPath } from "../utils.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { resolveBundledPluginSources } from "./bundled-sources.js";
import { installPluginFromNpmSpec, resolvePluginInstallDir } from "./install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
import { loadPluginManifest } from "./manifest.js";
export type PluginUpdateLogger = {
info?: (message: string) => void;
@@ -54,12 +53,6 @@ export type PluginChannelSyncResult = {
summary: PluginChannelSyncSummary;
};
type BundledPluginSource = {
pluginId: string;
localPath: string;
npmSpec?: string;
};
type InstallIntegrityDrift = {
spec: string;
expectedIntegrity: string;
@@ -91,40 +84,6 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
}
}
function resolveBundledPluginSources(params: {
workspaceDir?: string;
}): Map<string, BundledPluginSource> {
const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir });
const bundled = new Map<string, BundledPluginSource>();
for (const candidate of discovery.candidates) {
if (candidate.origin !== "bundled") {
continue;
}
const manifest = loadPluginManifest(candidate.rootDir);
if (!manifest.ok) {
continue;
}
const pluginId = manifest.manifest.id;
if (bundled.has(pluginId)) {
continue;
}
const npmSpec =
candidate.packageManifest?.install?.npmSpec?.trim() ||
candidate.packageName?.trim() ||
undefined;
bundled.set(pluginId, {
pluginId,
localPath: candidate.rootDir,
npmSpec,
});
}
return bundled;
}
function pathsEqual(left?: string, right?: string): boolean {
if (!left || !right) {
return false;