fix(plugins): resolve official plugin install aliases

Resolve bare official external plugin IDs through the official catalog before generic npm fallback, preserving explicit npm semantics and catalog integrity through the hook-pack fallback.\n\nFixes #76373.\n\nThanks @bek91 and @vincentkoc.
This commit is contained in:
Bek
2026-05-03 01:27:13 -04:00
committed by GitHub
parent f696be950b
commit 411df59916
6 changed files with 262 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc.
- Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev.
- Channels/WhatsApp: attach native outbound mention metadata for group text and media captions by resolving `@+<digits>` and `@<digits>` tokens against WhatsApp participant data, including LID groups. Fixes #39879; carries forward #56863. Thanks @kengi1437, @joe2643, and @fridayck.
- Plugins/install: resolve bare official external plugin IDs such as `brave` through the official catalog when no bundled source is available, so packaged installs fetch the intended scoped npm package instead of an unrelated unscoped package. Fixes #76373. Thanks @bek91 and @vincentkoc.
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.

View File

@@ -5,6 +5,7 @@ import {
resolveBundledInstallPlanForCatalogEntry,
resolveBundledInstallPlanBeforeNpm,
resolveBundledInstallPlanForNpmFailure,
resolveOfficialExternalInstallPlanBeforeNpm,
} from "./plugin-install-plan.js";
describe("plugin install plan helpers", () => {
@@ -36,6 +37,55 @@ describe("plugin install plan helpers", () => {
expect(result).toBeNull();
});
it("resolves exact official external plugin ids before npm fallback", () => {
const findOfficialExternalPlugin = vi.fn().mockReturnValue({
pluginId: "brave",
npmSpec: "@openclaw/brave-plugin",
expectedIntegrity: "sha512-brave",
});
const result = resolveOfficialExternalInstallPlanBeforeNpm({
rawSpec: "brave",
findOfficialExternalPlugin,
});
expect(findOfficialExternalPlugin).toHaveBeenCalledWith("brave");
expect(result).toEqual({
pluginId: "brave",
npmSpec: "@openclaw/brave-plugin",
expectedIntegrity: "sha512-brave",
});
});
it("skips official external plan for explicit npm selectors", () => {
const findOfficialExternalPlugin = vi.fn();
expect(
resolveOfficialExternalInstallPlanBeforeNpm({
rawSpec: "brave@beta",
findOfficialExternalPlugin,
}),
).toBeNull();
expect(
resolveOfficialExternalInstallPlanBeforeNpm({
rawSpec: "@openclaw/brave-plugin",
findOfficialExternalPlugin,
}),
).toBeNull();
expect(findOfficialExternalPlugin).not.toHaveBeenCalled();
});
it("skips official external plan without an npm install spec", () => {
const result = resolveOfficialExternalInstallPlanBeforeNpm({
rawSpec: "brave",
findOfficialExternalPlugin: vi.fn().mockReturnValue({
pluginId: "brave",
}),
});
expect(result).toBeNull();
});
it("prefers bundled catalog plugin by id before npm spec", () => {
const findBundledSource = vi
.fn()

View File

@@ -7,6 +7,14 @@ type BundledLookup = (params: {
value: string;
}) => BundledPluginSource | undefined;
type OfficialExternalPluginLookup = (pluginId: string) =>
| {
pluginId: string;
npmSpec?: string;
expectedIntegrity?: string;
}
| undefined;
function isBareNpmPackageName(spec: string): boolean {
const trimmed = spec.trim();
return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed);
@@ -65,6 +73,25 @@ export function resolveBundledInstallPlanBeforeNpm(params: {
};
}
export function resolveOfficialExternalInstallPlanBeforeNpm(params: {
rawSpec: string;
findOfficialExternalPlugin: OfficialExternalPluginLookup;
}): { pluginId: string; npmSpec: string; expectedIntegrity?: string } | null {
if (!isBareNpmPackageName(params.rawSpec)) {
return null;
}
const entry = params.findOfficialExternalPlugin(params.rawSpec);
const npmSpec = entry?.npmSpec?.trim();
if (!entry?.pluginId || !npmSpec) {
return null;
}
return {
pluginId: entry.pluginId,
npmSpec,
...(entry.expectedIntegrity ? { expectedIntegrity: entry.expectedIntegrity } : {}),
};
}
export function resolveBundledInstallPlanForNpmFailure(params: {
rawSpec: string;
code?: string;

View File

@@ -78,6 +78,7 @@ export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn();
export const installPluginFromPath: AsyncUnknownMock = vi.fn();
export const installPluginFromClawHub: AsyncUnknownMock = vi.fn();
export const parseClawHubPluginSpec: Mock<ParseClawHubPluginSpecFn> = vi.fn();
export const findBundledPluginSourceMock: UnknownMock = vi.fn();
export const installHooksFromNpmSpec: AsyncUnknownMock = vi.fn();
export const installHooksFromPath: AsyncUnknownMock = vi.fn();
export const recordHookInstall: UnknownMock = vi.fn();
@@ -485,6 +486,26 @@ vi.mock("../plugins/install.js", () => ({
)) as (typeof import("../plugins/install.js"))["installPluginFromPath"],
}));
vi.mock("../plugins/bundled-sources.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/bundled-sources.js")>();
return {
...actual,
findBundledPluginSource: ((
...args: Parameters<
(typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"]
>
) => {
if (findBundledPluginSourceMock.getMockImplementation()) {
return invokeMock<
Parameters<(typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"]>,
ReturnType<(typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"]>
>(findBundledPluginSourceMock, ...args);
}
return actual.findBundledPluginSource(...args);
}) as (typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"],
};
});
vi.mock("../plugins/git-install.js", () => ({
installPluginFromGitSpec: ((
...args: Parameters<(typeof import("../plugins/git-install.js"))["installPluginFromGitSpec"]>
@@ -621,6 +642,7 @@ export function resetPluginsCliTestState() {
installPluginFromPath.mockReset();
installPluginFromClawHub.mockReset();
parseClawHubPluginSpec.mockReset();
findBundledPluginSourceMock.mockReset();
installHooksFromNpmSpec.mockReset();
installHooksFromPath.mockReset();
recordHookInstall.mockReset();

View File

@@ -8,6 +8,7 @@ import {
applyExclusiveSlotSelection,
buildPluginSnapshotReport,
enablePluginInConfig,
findBundledPluginSourceMock,
installHooksFromNpmSpec,
installHooksFromPath,
installPluginFromClawHub,
@@ -652,7 +653,91 @@ describe("plugins cli install", () => {
});
});
it("installs bare plugin specs through npm without ClawHub lookup", async () => {
it("resolves exact official external plugin ids through their npm package", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("brave");
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue(undefined);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("brave"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "brave"]);
expect(findBundledPluginSourceMock).toHaveBeenCalledWith({
lookup: { kind: "pluginId", value: "brave" },
});
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/brave-plugin",
expectedPluginId: "brave",
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
brave: expect.objectContaining({
source: "npm",
spec: "@openclaw/brave-plugin",
installPath: cliInstallPath("brave"),
version: "1.2.3",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("passes official external catalog integrity to npm installs", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("wecom");
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue(undefined);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("wecom"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "wecom"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@2026.4.23",
expectedPluginId: "wecom",
expectedIntegrity:
"sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==",
}),
);
});
it("passes official external catalog integrity to hook-pack fallback", async () => {
loadConfig.mockReturnValue(createEmptyPluginConfig());
findBundledPluginSourceMock.mockReturnValue(undefined);
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.extensions",
code: "missing_openclaw_extensions",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: false,
error:
"aborted: npm package integrity drift detected for @wecom/wecom-openclaw-plugin@2026.4.23",
});
await expect(runPluginsCommand(["plugins", "install", "wecom"])).rejects.toThrow("__exit__:1");
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@2026.4.23",
expectedIntegrity:
"sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==",
}),
);
});
it("installs ordinary bare plugin specs through npm without ClawHub lookup", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
@@ -735,6 +820,34 @@ describe("plugins cli install", () => {
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("keeps npm-prefixed official plugin ids on explicit npm semantics", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("brave");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("brave"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "npm:brave"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "brave",
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.not.objectContaining({
expectedPluginId: "brave",
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
});
it("passes the active profile extensions dir to npm installs", async () => {
const extensionsDir = useProfileExtensionsDir();
const cfg = createEmptyPluginConfig();

View File

@@ -21,6 +21,11 @@ import {
installPluginFromMarketplace,
resolveMarketplaceInstallShortcut,
} from "../plugins/marketplace.js";
import {
getOfficialExternalPluginCatalogEntry,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
} from "../plugins/official-external-plugin-catalog.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
@@ -36,6 +41,7 @@ import {
import {
resolveBundledInstallPlanBeforeNpm,
resolveBundledInstallPlanForNpmFailure,
resolveOfficialExternalInstallPlanBeforeNpm,
} from "./plugin-install-plan.js";
import {
createHookPackInstallLogger,
@@ -231,11 +237,13 @@ async function tryInstallHookPackFromNpmSpec(params: {
installMode: "install" | "update";
spec: string;
pin?: boolean;
expectedIntegrity?: string;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const result = await installHooksFromNpmSpec({
spec: params.spec,
mode: params.installMode,
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
logger: createHookPackInstallLogger(params.runtime),
});
if (!result.ok) {
@@ -269,12 +277,16 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
safetyOverrides: InstallSafetyOverrides;
allowBundledFallback: boolean;
extensionsDir: string;
expectedPluginId?: string;
expectedIntegrity?: string;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
const result = await installPluginFromNpmSpec({
...params.safetyOverrides,
mode: params.installMode,
spec: params.spec,
...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}),
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
extensionsDir: params.extensionsDir,
logger: createPluginInstallLogger(params.runtime),
});
@@ -305,6 +317,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
installMode: params.installMode,
spec: params.spec,
pin: params.pin,
expectedIntegrity: params.expectedIntegrity,
runtime: params.runtime,
});
if (hookFallback.ok) {
@@ -747,6 +760,41 @@ export async function runPluginInstallCommand(params: {
return;
}
const officialExternalPlan = resolveOfficialExternalInstallPlanBeforeNpm({
rawSpec: raw,
findOfficialExternalPlugin: (pluginId) => {
const entry = getOfficialExternalPluginCatalogEntry(pluginId);
const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined;
const install = entry ? resolveOfficialExternalPluginInstall(entry) : null;
const npmSpec = install?.npmSpec;
return resolvedPluginId && npmSpec
? {
pluginId: resolvedPluginId,
npmSpec,
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
}
: undefined;
},
});
if (officialExternalPlan) {
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
installMode,
spec: officialExternalPlan.npmSpec,
pin: opts.pin,
safetyOverrides,
allowBundledFallback: false,
extensionsDir,
expectedPluginId: officialExternalPlan.pluginId,
expectedIntegrity: officialExternalPlan.expectedIntegrity,
runtime,
});
if (!npmResult.ok) {
return runtime.exit(1);
}
return;
}
const clawhubSpec = parseClawHubPluginSpec(raw);
if (clawhubSpec) {
const result = await installPluginFromClawHub({