mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user