mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 21:51:33 +00:00
* refactor: remove obsolete runtime and test duplication * test: preserve typed cron and realtime fixtures
4969 lines
165 KiB
TypeScript
4969 lines
165 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { bundledPluginRootAt } from "openclaw/plugin-sdk/test-fixtures";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { withEnvAsync } from "../test-utils/env.js";
|
|
|
|
const APP_ROOT = "/app";
|
|
|
|
type NpmInstallIntegrityDrift = {
|
|
spec: string;
|
|
expectedIntegrity: string;
|
|
actualIntegrity: string;
|
|
resolution: {
|
|
integrity?: string;
|
|
resolvedSpec?: string;
|
|
version?: string;
|
|
};
|
|
};
|
|
|
|
const appBundledPluginRoot = (pluginId: string) => bundledPluginRootAt(APP_ROOT, pluginId);
|
|
|
|
function requireExpectedPluginId(params: { expectedPluginId?: string }): string {
|
|
if (!params.expectedPluginId) {
|
|
throw new Error("Expected npm install params to include expectedPluginId");
|
|
}
|
|
return params.expectedPluginId;
|
|
}
|
|
|
|
function requirePluginPackageName(
|
|
plugins: Array<{ pluginId: string; packageName: string }>,
|
|
pluginId: string,
|
|
): string {
|
|
const plugin = plugins.find((candidate) => candidate.pluginId === pluginId);
|
|
if (!plugin) {
|
|
throw new Error(`Expected plugin fixture ${pluginId}`);
|
|
}
|
|
return plugin.packageName;
|
|
}
|
|
|
|
const installPluginFromNpmSpecMock = vi.fn();
|
|
const installPluginFromMarketplaceMock = vi.fn();
|
|
const installPluginFromClawHubMock = vi.fn();
|
|
const installPluginFromGitSpecMock = vi.fn();
|
|
const resolveBundledPluginSourcesMock = vi.fn();
|
|
const runCommandWithTimeoutMock = vi.fn();
|
|
const validatePackageExtensionEntriesForInstallMock = vi.fn();
|
|
const markClawPackageIndependentlyOwnedMock = vi.fn();
|
|
const withClawPackageLifecycleLeaseMock = vi.fn(
|
|
async (_artifact: unknown, operation: () => Promise<unknown>, _options?: unknown) =>
|
|
await operation(),
|
|
);
|
|
const tempDirs: string[] = [];
|
|
|
|
vi.mock("./install.js", () => ({
|
|
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpecMock(...args),
|
|
resolvePluginInstallDir: (pluginId: string, extensionsDir = "/tmp") => {
|
|
const separator = process.platform === "win32" ? "\\" : "/";
|
|
return `${extensionsDir.replace(/[\\/]+$/, "")}${separator}${pluginId}`;
|
|
},
|
|
PLUGIN_INSTALL_ERROR_CODE: {
|
|
NPM_METADATA_FAILURE: "npm_metadata_failure",
|
|
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
|
|
},
|
|
}));
|
|
|
|
vi.mock("./git-install.js", () => ({
|
|
installPluginFromGitSpec: (...args: unknown[]) => installPluginFromGitSpecMock(...args),
|
|
}));
|
|
|
|
vi.mock("./marketplace.js", () => ({
|
|
installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplaceMock(...args),
|
|
}));
|
|
|
|
vi.mock("./clawhub.js", () => ({
|
|
CLAWHUB_INSTALL_ERROR_CODE: {
|
|
PACKAGE_NOT_FOUND: "package_not_found",
|
|
VERSION_NOT_FOUND: "version_not_found",
|
|
ARTIFACT_UNAVAILABLE: "artifact_unavailable",
|
|
ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch",
|
|
ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable",
|
|
CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable",
|
|
CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required",
|
|
CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked",
|
|
},
|
|
installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHubMock(...args),
|
|
}));
|
|
|
|
vi.mock("../state/claw-package-adoption.js", () => ({
|
|
markClawPackageIndependentlyOwned: (...args: unknown[]) =>
|
|
markClawPackageIndependentlyOwnedMock(...args),
|
|
}));
|
|
|
|
vi.mock("../state/claw-package-lifecycle-lease.js", () => ({
|
|
withClawPackageLifecycleLease: (
|
|
artifact: unknown,
|
|
operation: () => Promise<unknown>,
|
|
options?: unknown,
|
|
) => withClawPackageLifecycleLeaseMock(artifact, operation, options),
|
|
}));
|
|
|
|
vi.mock("./bundled-sources.js", () => ({
|
|
resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args),
|
|
}));
|
|
|
|
vi.mock("../process/exec.js", () => ({
|
|
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
|
}));
|
|
|
|
vi.mock("./package-entry-resolution.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./package-entry-resolution.js")>();
|
|
return {
|
|
...actual,
|
|
validatePackageExtensionEntriesForInstall: async (
|
|
...args: Parameters<typeof actual.validatePackageExtensionEntriesForInstall>
|
|
) => {
|
|
validatePackageExtensionEntriesForInstallMock(...args);
|
|
return await actual.validatePackageExtensionEntriesForInstall(...args);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.resetModules();
|
|
|
|
const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = await import("./update.js");
|
|
|
|
function createSuccessfulNpmUpdateResult(params?: {
|
|
pluginId?: string;
|
|
targetDir?: string;
|
|
version?: string;
|
|
npmResolution?: {
|
|
name: string;
|
|
version: string;
|
|
resolvedSpec: string;
|
|
};
|
|
}) {
|
|
return {
|
|
ok: true,
|
|
pluginId: params?.pluginId ?? "opik-openclaw",
|
|
targetDir: params?.targetDir ?? "/tmp/opik-openclaw",
|
|
version: params?.version ?? "0.2.6",
|
|
extensions: ["index.ts"],
|
|
...(params?.npmResolution ? { npmResolution: params.npmResolution } : {}),
|
|
};
|
|
}
|
|
|
|
function createSuccessfulClawHubUpdateResult(params?: {
|
|
pluginId?: string;
|
|
targetDir?: string;
|
|
version?: string;
|
|
clawhubPackage?: string;
|
|
}) {
|
|
return {
|
|
ok: true,
|
|
pluginId: params?.pluginId ?? "legacy-chat",
|
|
targetDir: params?.targetDir ?? "/tmp/openclaw-plugins/legacy-chat",
|
|
version: params?.version ?? "2026.5.1-beta.2",
|
|
extensions: ["index.ts"],
|
|
packageName: params?.clawhubPackage ?? "legacy-chat",
|
|
clawhub: {
|
|
source: "clawhub" as const,
|
|
clawhubUrl: "https://clawhub.ai",
|
|
clawhubPackage: params?.clawhubPackage ?? "legacy-chat",
|
|
clawhubFamily: "code-plugin" as const,
|
|
clawhubChannel: "official" as const,
|
|
version: params?.version ?? "2026.5.1-beta.2",
|
|
integrity: "sha256-clawpack",
|
|
resolvedAt: "2026-05-01T00:00:00.000Z",
|
|
artifactKind: "npm-pack" as const,
|
|
artifactFormat: "tgz" as const,
|
|
npmIntegrity: "sha512-clawpack",
|
|
npmShasum: "2".repeat(40),
|
|
npmTarballName: `${params?.clawhubPackage ?? "legacy-chat"}-${params?.version ?? "2026.5.1-beta.2"}.tgz`,
|
|
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
clawpackSpecVersion: 1,
|
|
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
clawpackSize: 4096,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createNpmInstallConfig(params: {
|
|
pluginId: string;
|
|
spec: string;
|
|
installPath: string;
|
|
integrity?: string;
|
|
shasum?: string;
|
|
installedAt?: string;
|
|
resolvedAt?: string;
|
|
resolvedName?: string;
|
|
resolvedSpec?: string;
|
|
resolvedVersion?: string;
|
|
}) {
|
|
return {
|
|
plugins: {
|
|
installs: {
|
|
[params.pluginId]: {
|
|
source: "npm" as const,
|
|
spec: params.spec,
|
|
installPath: params.installPath,
|
|
...(params.integrity ? { integrity: params.integrity } : {}),
|
|
...(params.shasum ? { shasum: params.shasum } : {}),
|
|
...(params.resolvedName ? { resolvedName: params.resolvedName } : {}),
|
|
...(params.resolvedSpec ? { resolvedSpec: params.resolvedSpec } : {}),
|
|
...(params.resolvedVersion ? { resolvedVersion: params.resolvedVersion } : {}),
|
|
...(params.installedAt ? { installedAt: params.installedAt } : {}),
|
|
...(params.resolvedAt ? { resolvedAt: params.resolvedAt } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createMarketplaceInstallConfig(params: {
|
|
pluginId: string;
|
|
installPath: string;
|
|
marketplaceSource: string;
|
|
marketplacePlugin: string;
|
|
marketplaceName?: string;
|
|
}): OpenClawConfig {
|
|
return {
|
|
plugins: {
|
|
installs: {
|
|
[params.pluginId]: {
|
|
source: "marketplace" as const,
|
|
installPath: params.installPath,
|
|
marketplaceSource: params.marketplaceSource,
|
|
marketplacePlugin: params.marketplacePlugin,
|
|
...(params.marketplaceName ? { marketplaceName: params.marketplaceName } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createClawHubInstallConfig(
|
|
params: {
|
|
pluginId?: string;
|
|
installPath?: string;
|
|
clawhubUrl?: string;
|
|
clawhubPackage?: string;
|
|
clawhubFamily?: "bundle-plugin" | "code-plugin";
|
|
clawhubChannel?: "community" | "official" | "private";
|
|
spec?: string;
|
|
} = {},
|
|
): OpenClawConfig {
|
|
const pluginId = params.pluginId ?? "demo";
|
|
const clawhubPackage = params.clawhubPackage ?? pluginId;
|
|
return {
|
|
plugins: {
|
|
installs: {
|
|
[pluginId]: {
|
|
source: "clawhub" as const,
|
|
spec: params.spec ?? `clawhub:${clawhubPackage}`,
|
|
installPath: params.installPath ?? `/tmp/${pluginId}`,
|
|
clawhubUrl: params.clawhubUrl ?? "https://clawhub.ai",
|
|
clawhubPackage,
|
|
clawhubFamily: params.clawhubFamily ?? "code-plugin",
|
|
clawhubChannel: params.clawhubChannel ?? "official",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createEnabledDemoClawHubInstallConfig(): OpenClawConfig {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "demo",
|
|
version: "1.2.3",
|
|
});
|
|
const config = createClawHubInstallConfig({ installPath });
|
|
config.plugins = {
|
|
...config.plugins,
|
|
entries: {
|
|
demo: {
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
allow: ["demo"],
|
|
slots: {
|
|
memory: "demo",
|
|
},
|
|
};
|
|
return config;
|
|
}
|
|
|
|
function createGitInstallConfig(params: {
|
|
pluginId: string;
|
|
spec: string;
|
|
installPath: string;
|
|
commit?: string;
|
|
}): OpenClawConfig {
|
|
return {
|
|
plugins: {
|
|
installs: {
|
|
[params.pluginId]: {
|
|
source: "git" as const,
|
|
spec: params.spec,
|
|
installPath: params.installPath,
|
|
...(params.commit ? { gitCommit: params.commit } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createBundledPathInstallConfig(params: {
|
|
loadPaths: string[];
|
|
installPath: string;
|
|
sourcePath?: string;
|
|
spec?: string;
|
|
}): OpenClawConfig {
|
|
return {
|
|
plugins: {
|
|
load: { paths: params.loadPaths },
|
|
installs: {
|
|
feishu: {
|
|
source: "path",
|
|
sourcePath: params.sourcePath ?? appBundledPluginRoot("feishu"),
|
|
installPath: params.installPath,
|
|
...(params.spec ? { spec: params.spec } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createCodexAppServerInstallConfig(params: {
|
|
spec: string;
|
|
resolvedName?: string;
|
|
resolvedSpec?: string;
|
|
}) {
|
|
return {
|
|
plugins: {
|
|
installs: {
|
|
"openclaw-codex-app-server": {
|
|
source: "npm" as const,
|
|
spec: params.spec,
|
|
installPath: "/tmp/openclaw-codex-app-server",
|
|
...(params.resolvedName ? { resolvedName: params.resolvedName } : {}),
|
|
...(params.resolvedSpec ? { resolvedSpec: params.resolvedSpec } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createInstalledPackageDir(params: {
|
|
name?: string;
|
|
version: string;
|
|
peerDependencies?: Record<string, string>;
|
|
runnable?: boolean;
|
|
}): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-test-"));
|
|
tempDirs.push(dir);
|
|
fs.writeFileSync(
|
|
path.join(dir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: params.name ?? "test-plugin",
|
|
version: params.version,
|
|
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
|
|
...(params.runnable ? { openclaw: { extensions: ["./index.js"] } } : {}),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
if (params.runnable) {
|
|
fs.writeFileSync(path.join(dir, "index.js"), "export default function register() {}\n");
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function createOpenClawPeerLinkFixtures(plugins: Array<{ pluginId: string; packageName: string }>) {
|
|
const peerTarget = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-peer-target-"));
|
|
tempDirs.push(peerTarget);
|
|
const installPaths = Object.fromEntries(
|
|
plugins.map(({ pluginId, packageName }) => [
|
|
pluginId,
|
|
createInstalledPackageDir({
|
|
name: packageName,
|
|
version: "2026.5.4",
|
|
peerDependencies: { openclaw: ">=2026.5.4" },
|
|
}),
|
|
]),
|
|
);
|
|
const peerLinkPath = (pluginId: string) =>
|
|
path.join(
|
|
expectDefined(installPaths[pluginId], "installPaths[pluginId] test invariant"),
|
|
"node_modules",
|
|
"openclaw",
|
|
);
|
|
const linkPeer = (pluginId: string) => {
|
|
fs.mkdirSync(path.dirname(peerLinkPath(pluginId)), { recursive: true });
|
|
fs.symlinkSync(peerTarget, peerLinkPath(pluginId), "junction");
|
|
};
|
|
return { installPaths, peerLinkPath, linkPeer };
|
|
}
|
|
|
|
function createPeerLinkInstallConfig(params: {
|
|
plugins: Array<{ pluginId: string; packageName: string }>;
|
|
installPaths: Record<string, string>;
|
|
extraInstalls?: Record<string, PluginInstallRecord>;
|
|
}): OpenClawConfig {
|
|
return {
|
|
plugins: {
|
|
installs: {
|
|
...params.extraInstalls,
|
|
...Object.fromEntries(
|
|
params.plugins.map(({ pluginId, packageName }) => [
|
|
pluginId,
|
|
{
|
|
source: "npm",
|
|
spec: packageName,
|
|
installPath: params.installPaths[pluginId],
|
|
resolvedName: packageName,
|
|
resolvedVersion: "2026.5.4",
|
|
resolvedSpec: `${packageName}@2026.5.4`,
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
},
|
|
]),
|
|
),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function mockNpmViewMetadata(params: {
|
|
name: string;
|
|
version: string;
|
|
integrity?: string;
|
|
shasum?: string;
|
|
openclaw?: Record<string, unknown>;
|
|
}) {
|
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
|
code: 0,
|
|
stdout: JSON.stringify({
|
|
name: params.name,
|
|
version: params.version,
|
|
...(params.integrity ? { "dist.integrity": params.integrity } : {}),
|
|
...(params.shasum ? { "dist.shasum": params.shasum } : {}),
|
|
...(params.openclaw ? { openclaw: params.openclaw } : {}),
|
|
}),
|
|
stderr: "",
|
|
});
|
|
}
|
|
|
|
function mockNpmViewVersions(versions: string[]) {
|
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
|
code: 0,
|
|
stdout: JSON.stringify(versions),
|
|
stderr: "",
|
|
});
|
|
}
|
|
|
|
function createNpmUpdateFixture(params: {
|
|
pluginId: string;
|
|
packageName: string;
|
|
installedVersion: string;
|
|
registryVersion?: string;
|
|
registryIntegrity?: string;
|
|
registryShasum?: string;
|
|
registryOpenClaw?: Record<string, unknown>;
|
|
spec?: string;
|
|
resolvedSpec?: string;
|
|
integrity?: string;
|
|
shasum?: string;
|
|
installedAt?: string;
|
|
resolvedAt?: string;
|
|
installerVersion?: string;
|
|
installerResolvedSpec?: string;
|
|
peerDependencies?: Record<string, string>;
|
|
}) {
|
|
const installPath = createInstalledPackageDir({
|
|
name: params.packageName,
|
|
version: params.installedVersion,
|
|
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
|
|
});
|
|
if (params.registryVersion) {
|
|
mockNpmViewMetadata({
|
|
name: params.packageName,
|
|
version: params.registryVersion,
|
|
...(params.registryIntegrity ? { integrity: params.registryIntegrity } : {}),
|
|
...(params.registryShasum ? { shasum: params.registryShasum } : {}),
|
|
...(params.registryOpenClaw ? { openclaw: params.registryOpenClaw } : {}),
|
|
});
|
|
}
|
|
if (params.installerVersion) {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: params.pluginId,
|
|
targetDir: installPath,
|
|
version: params.installerVersion,
|
|
...(params.installerResolvedSpec
|
|
? {
|
|
npmResolution: {
|
|
name: params.packageName,
|
|
version: params.installerVersion,
|
|
resolvedSpec: params.installerResolvedSpec,
|
|
},
|
|
}
|
|
: {}),
|
|
}),
|
|
);
|
|
}
|
|
return {
|
|
installPath,
|
|
config: createNpmInstallConfig({
|
|
pluginId: params.pluginId,
|
|
spec: params.spec ?? params.packageName,
|
|
installPath,
|
|
resolvedName: params.packageName,
|
|
resolvedSpec: params.resolvedSpec ?? `${params.packageName}@${params.installedVersion}`,
|
|
resolvedVersion: params.installedVersion,
|
|
...(params.integrity ? { integrity: params.integrity } : {}),
|
|
...(params.shasum ? { shasum: params.shasum } : {}),
|
|
...(params.installedAt ? { installedAt: params.installedAt } : {}),
|
|
...(params.resolvedAt ? { resolvedAt: params.resolvedAt } : {}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function npmInstallCall(index = 0): Record<string, unknown> | undefined {
|
|
const calls = installPluginFromNpmSpecMock.mock.calls as unknown as Array<
|
|
[Record<string, unknown>]
|
|
>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function clawHubInstallCall(index = 0): Record<string, unknown> | undefined {
|
|
const calls = installPluginFromClawHubMock.mock.calls as unknown as Array<
|
|
[Record<string, unknown>]
|
|
>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function marketplaceInstallCall(index = 0): Record<string, unknown> | undefined {
|
|
const calls = installPluginFromMarketplaceMock.mock.calls as unknown as Array<
|
|
[Record<string, unknown>]
|
|
>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function gitInstallCall(index = 0): Record<string, unknown> | undefined {
|
|
const calls = installPluginFromGitSpecMock.mock.calls as unknown as Array<
|
|
[Record<string, unknown>]
|
|
>;
|
|
return calls[index]?.[0];
|
|
}
|
|
|
|
function npmViewCall(): [unknown, Record<string, unknown>] | undefined {
|
|
const calls = runCommandWithTimeoutMock.mock.calls as unknown as Array<
|
|
[unknown, Record<string, unknown>]
|
|
>;
|
|
return calls.find(([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "view");
|
|
}
|
|
|
|
function expectRecordFields(
|
|
actual: Record<string, unknown> | undefined,
|
|
expected: Record<string, unknown>,
|
|
) {
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
expect(actual?.[key]).toEqual(value);
|
|
}
|
|
}
|
|
|
|
function expectNpmUpdateCall(params: {
|
|
spec: string;
|
|
expectedIntegrity?: string;
|
|
expectedPluginId?: string;
|
|
timeoutMs?: number;
|
|
}) {
|
|
const call = npmInstallCall();
|
|
expect(call?.spec).toBe(params.spec);
|
|
expect(call?.expectedIntegrity).toBe(params.expectedIntegrity);
|
|
if (params.expectedPluginId) {
|
|
expect(call?.expectedPluginId).toBe(params.expectedPluginId);
|
|
}
|
|
if (params.timeoutMs) {
|
|
expect(call?.timeoutMs).toBe(params.timeoutMs);
|
|
}
|
|
}
|
|
|
|
function createBundledSource(params?: { pluginId?: string; localPath?: string; npmSpec?: string }) {
|
|
const pluginId = params?.pluginId ?? "feishu";
|
|
return {
|
|
pluginId,
|
|
localPath: params?.localPath ?? appBundledPluginRoot(pluginId),
|
|
npmSpec: params?.npmSpec ?? `@openclaw/${pluginId}`,
|
|
};
|
|
}
|
|
|
|
type ExternalizedPluginBridge = NonNullable<
|
|
Parameters<typeof syncPluginsForUpdateChannel>[0]["externalizedBundledPluginBridges"]
|
|
>[number];
|
|
type PluginInstallRecord = NonNullable<NonNullable<OpenClawConfig["plugins"]>["installs"]>[string];
|
|
|
|
function createDisabledPluginConfig(install: PluginInstallRecord): OpenClawConfig {
|
|
return {
|
|
plugins: {
|
|
entries: { demo: { enabled: false, config: { preserved: true } } },
|
|
installs: { demo: install },
|
|
},
|
|
};
|
|
}
|
|
|
|
function createExternalizedPluginBridge(
|
|
overrides: Partial<ExternalizedPluginBridge> = {},
|
|
): ExternalizedPluginBridge {
|
|
return {
|
|
bundledPluginId: "legacy-chat",
|
|
npmSpec: "@openclaw/legacy-chat",
|
|
channelIds: ["legacy-chat"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createExternalizedPluginConfig(params?: {
|
|
pluginId?: string;
|
|
channelEnabled?: boolean;
|
|
entryEnabled?: boolean;
|
|
includeLoad?: boolean;
|
|
loadPaths?: string[];
|
|
install?: PluginInstallRecord;
|
|
}): OpenClawConfig {
|
|
const pluginId = params?.pluginId ?? "legacy-chat";
|
|
const bundledRoot = appBundledPluginRoot(pluginId);
|
|
return {
|
|
...(params?.channelEnabled === false ? {} : { channels: { [pluginId]: { enabled: true } } }),
|
|
plugins: {
|
|
...(params?.entryEnabled === undefined
|
|
? {}
|
|
: { entries: { [pluginId]: { enabled: params.entryEnabled } } }),
|
|
...(params?.includeLoad === false
|
|
? {}
|
|
: { load: { paths: params?.loadPaths ?? [bundledRoot] } }),
|
|
installs: {
|
|
[pluginId]:
|
|
params?.install ??
|
|
({ source: "path", sourcePath: bundledRoot, installPath: bundledRoot } as const),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function syncExternalizedPlugin(params: {
|
|
config?: OpenClawConfig;
|
|
bridge?: Partial<ExternalizedPluginBridge>;
|
|
channel?: "stable" | "beta" | "extended-stable";
|
|
coreVersion?: string;
|
|
acknowledgeClawHubRisk?: boolean;
|
|
onClawHubRisk?: Parameters<typeof syncPluginsForUpdateChannel>[0]["onClawHubRisk"];
|
|
}) {
|
|
return syncPluginsForUpdateChannel({
|
|
channel: params.channel ?? "stable",
|
|
...(params.coreVersion ? { coreVersion: params.coreVersion } : {}),
|
|
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
|
|
...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}),
|
|
externalizedBundledPluginBridges: [createExternalizedPluginBridge(params.bridge)],
|
|
config: params.config ?? createExternalizedPluginConfig(),
|
|
});
|
|
}
|
|
|
|
function mockBundledSources(...sources: ReturnType<typeof createBundledSource>[]) {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(
|
|
new Map(sources.map((source) => [source.pluginId, source])),
|
|
);
|
|
}
|
|
|
|
function expectBundledPathInstall(params: {
|
|
install: Record<string, unknown> | undefined;
|
|
sourcePath: string;
|
|
installPath: string;
|
|
spec?: string;
|
|
}) {
|
|
expect(params.install?.source).toBe("path");
|
|
expect(params.install?.sourcePath).toBe(params.sourcePath);
|
|
expect(params.install?.installPath).toBe(params.installPath);
|
|
if (params.spec) {
|
|
expect(params.install?.spec).toBe(params.spec);
|
|
}
|
|
}
|
|
|
|
function expectCodexAppServerInstallState(params: {
|
|
result: Awaited<ReturnType<typeof updateNpmInstalledPlugins>>;
|
|
spec: string;
|
|
version: string;
|
|
resolvedSpec?: string;
|
|
}) {
|
|
const install = params.result.config.plugins?.installs?.["openclaw-codex-app-server"];
|
|
expect(install?.source).toBe("npm");
|
|
expect(install?.spec).toBe(params.spec);
|
|
expect(install?.installPath).toBe("/tmp/openclaw-codex-app-server");
|
|
expect(install?.version).toBe(params.version);
|
|
if (params.resolvedSpec) {
|
|
expect(install?.resolvedSpec).toBe(params.resolvedSpec);
|
|
}
|
|
}
|
|
|
|
type UpdateInstalledPluginParams = Parameters<typeof updateNpmInstalledPlugins>[0];
|
|
|
|
function updatePlugin(
|
|
config: OpenClawConfig,
|
|
pluginId: string,
|
|
params: Omit<UpdateInstalledPluginParams, "config" | "pluginIds"> = {},
|
|
) {
|
|
return updateNpmInstalledPlugins({ config, pluginIds: [pluginId], ...params });
|
|
}
|
|
|
|
describe("updateNpmInstalledPlugins", () => {
|
|
let timeoutBudgetCase: {
|
|
installCall: Record<string, unknown> | undefined;
|
|
npmViewTimeoutMs: unknown;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
installPluginFromNpmSpecMock.mockReset();
|
|
installPluginFromMarketplaceMock.mockReset();
|
|
installPluginFromClawHubMock.mockReset();
|
|
installPluginFromGitSpecMock.mockReset();
|
|
resolveBundledPluginSourcesMock.mockReset();
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
runCommandWithTimeoutMock.mockReset();
|
|
validatePackageExtensionEntriesForInstallMock.mockReset();
|
|
markClawPackageIndependentlyOwnedMock.mockReset();
|
|
withClawPackageLifecycleLeaseMock
|
|
.mockReset()
|
|
.mockImplementation(
|
|
async (_artifact: unknown, operation: () => Promise<unknown>) => await operation(),
|
|
);
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "lossless-claw",
|
|
packageName: "@martian-engineering/lossless-claw",
|
|
installedVersion: "0.9.0",
|
|
registryVersion: "0.10.0",
|
|
registryIntegrity: "sha512-next",
|
|
installerVersion: "0.10.0",
|
|
});
|
|
await updatePlugin(config, "lossless-claw", { timeoutMs: 1_800_000 });
|
|
|
|
timeoutBudgetCase = {
|
|
installCall: npmInstallCall(),
|
|
npmViewTimeoutMs: npmViewCall()?.[1]?.timeoutMs,
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
installPluginFromNpmSpecMock.mockReset();
|
|
installPluginFromMarketplaceMock.mockReset();
|
|
installPluginFromClawHubMock.mockReset();
|
|
installPluginFromGitSpecMock.mockReset();
|
|
resolveBundledPluginSourcesMock.mockReset();
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
runCommandWithTimeoutMock.mockReset();
|
|
validatePackageExtensionEntriesForInstallMock.mockReset();
|
|
});
|
|
|
|
it("moves only the replaced npm plugin's exact explicit load path", async () => {
|
|
const previousInstallPath = createInstalledPackageDir({
|
|
name: "@acme/demo",
|
|
version: "1.0.0",
|
|
});
|
|
const nextInstallPath = createInstalledPackageDir({
|
|
name: "@acme/demo",
|
|
version: "2.0.0",
|
|
});
|
|
const adjacentInstallPath = createInstalledPackageDir({
|
|
name: "@acme/adjacent",
|
|
version: "1.0.0",
|
|
});
|
|
const customPath = path.join(previousInstallPath, "custom-child");
|
|
mockNpmViewMetadata({ name: "@acme/demo", version: "2.0.0" });
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "demo",
|
|
targetDir: nextInstallPath,
|
|
version: "2.0.0",
|
|
npmResolution: {
|
|
name: "@acme/demo",
|
|
version: "2.0.0",
|
|
resolvedSpec: "@acme/demo@2.0.0",
|
|
},
|
|
}),
|
|
);
|
|
const adjacentRecord = {
|
|
source: "npm" as const,
|
|
spec: "@acme/adjacent@1.0.0",
|
|
installPath: adjacentInstallPath,
|
|
};
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
load: {
|
|
paths: [customPath, previousInstallPath, adjacentInstallPath],
|
|
},
|
|
installs: {
|
|
demo: {
|
|
source: "npm",
|
|
spec: "@acme/demo@1.0.0",
|
|
installPath: previousInstallPath,
|
|
resolvedName: "@acme/demo",
|
|
resolvedSpec: "@acme/demo@1.0.0",
|
|
resolvedVersion: "1.0.0",
|
|
},
|
|
adjacent: adjacentRecord,
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["demo"],
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.load?.paths).toEqual([
|
|
customPath,
|
|
nextInstallPath,
|
|
adjacentInstallPath,
|
|
]);
|
|
expect(result.config.plugins?.installs?.adjacent).toEqual(adjacentRecord);
|
|
});
|
|
|
|
it("preserves explicit load paths when the npm replacement fails", async () => {
|
|
const previousInstallPath = createInstalledPackageDir({
|
|
name: "@acme/demo",
|
|
version: "1.0.0",
|
|
});
|
|
const customPath = "/tmp/custom-demo";
|
|
mockNpmViewMetadata({ name: "@acme/demo", version: "2.0.0" });
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "npm install failed",
|
|
});
|
|
const config = {
|
|
plugins: {
|
|
load: { paths: [previousInstallPath, customPath] },
|
|
installs: {
|
|
demo: {
|
|
source: "npm" as const,
|
|
spec: "@acme/demo@1.0.0",
|
|
installPath: previousInstallPath,
|
|
resolvedName: "@acme/demo",
|
|
resolvedSpec: "@acme/demo@1.0.0",
|
|
resolvedVersion: "1.0.0",
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const result = await updateNpmInstalledPlugins({ config, pluginIds: ["demo"] });
|
|
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.config.plugins?.load?.paths).toEqual([previousInstallPath, customPath]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not treat inherited prototype names as install records", async () => {
|
|
const config: OpenClawConfig = { plugins: { installs: {} } };
|
|
|
|
const result = await updatePlugin(config, "constructor");
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "constructor",
|
|
status: "skipped",
|
|
message: 'No install record for "constructor".',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "skips integrity drift checks for unpinned npm specs during dry-run updates",
|
|
config: createNpmInstallConfig({
|
|
pluginId: "opik-openclaw",
|
|
spec: "@opik/opik-openclaw",
|
|
integrity: "sha512-old",
|
|
installPath: "/tmp/opik-openclaw",
|
|
}),
|
|
pluginIds: ["opik-openclaw"],
|
|
dryRun: true,
|
|
expectedCall: {
|
|
spec: "@opik/opik-openclaw",
|
|
expectedIntegrity: undefined,
|
|
},
|
|
},
|
|
{
|
|
name: "keeps integrity drift checks for exact-version npm specs during dry-run updates",
|
|
config: createNpmInstallConfig({
|
|
pluginId: "opik-openclaw",
|
|
spec: "@opik/opik-openclaw@0.2.5",
|
|
integrity: "sha512-old",
|
|
installPath: "/tmp/opik-openclaw",
|
|
}),
|
|
pluginIds: ["opik-openclaw"],
|
|
dryRun: true,
|
|
expectedCall: {
|
|
spec: "@opik/opik-openclaw@0.2.5",
|
|
expectedIntegrity: "sha512-old",
|
|
},
|
|
},
|
|
{
|
|
name: "skips recorded integrity checks when an explicit npm version override changes the spec",
|
|
config: createNpmInstallConfig({
|
|
pluginId: "openclaw-codex-app-server",
|
|
spec: "openclaw-codex-app-server@0.2.0-beta.3",
|
|
integrity: "sha512-old",
|
|
installPath: "/tmp/openclaw-codex-app-server",
|
|
}),
|
|
pluginIds: ["openclaw-codex-app-server"],
|
|
specOverrides: {
|
|
"openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4",
|
|
},
|
|
installerResult: createSuccessfulNpmUpdateResult({
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
}),
|
|
expectedCall: {
|
|
spec: "openclaw-codex-app-server@0.2.0-beta.4",
|
|
expectedIntegrity: undefined,
|
|
},
|
|
},
|
|
] as const)(
|
|
"$name",
|
|
async ({ config, pluginIds, dryRun, specOverrides, installerResult, expectedCall }) => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
installerResult ?? createSuccessfulNpmUpdateResult(),
|
|
);
|
|
|
|
await updateNpmInstalledPlugins({
|
|
config,
|
|
pluginIds: [...pluginIds],
|
|
...(dryRun ? { dryRun: true } : {}),
|
|
...(specOverrides ? { specOverrides } : {}),
|
|
});
|
|
|
|
expectNpmUpdateCall(expectedCall);
|
|
},
|
|
);
|
|
|
|
it("passes timeout budget to npm plugin metadata checks and installs", async () => {
|
|
expect(timeoutBudgetCase.npmViewTimeoutMs).toBe(1_800_000);
|
|
expectRecordFields(timeoutBudgetCase.installCall, {
|
|
spec: "@martian-engineering/lossless-claw",
|
|
expectedPluginId: "lossless-claw",
|
|
timeoutMs: 1_800_000,
|
|
});
|
|
});
|
|
|
|
it("trusts official catalog npm updates when the installed package matches the catalog", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2-beta.1",
|
|
registryVersion: "2026.5.2-beta.2",
|
|
installerVersion: "2026.5.2-beta.2",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.5.2-beta.2",
|
|
});
|
|
|
|
const result = await updatePlugin(config, "acpx", { syncOfficialPluginInstalls: true });
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/acpx");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("acpx");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
expect(result.config.plugins?.installs?.acpx?.spec).toBe("@openclaw/acpx@2026.5.2-beta.2");
|
|
});
|
|
|
|
it("pins unchanged official npm records during official sync", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2",
|
|
registryVersion: "2026.5.2",
|
|
registryIntegrity: "sha512-old",
|
|
integrity: "sha512-old",
|
|
installedAt: "2026-05-01T00:00:00.000Z",
|
|
resolvedAt: "2026-05-01T00:00:01.000Z",
|
|
});
|
|
const result = await updatePlugin(config, "acpx", { syncOfficialPluginInstalls: true });
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.outcomes[0]?.status).toBe("unchanged");
|
|
expect(result.config.plugins?.installs?.acpx?.spec).toBe("@openclaw/acpx@2026.5.2");
|
|
expect(result.config.plugins?.installs?.acpx?.installedAt).toBe("2026-05-01T00:00:00.000Z");
|
|
expect(result.config.plugins?.installs?.acpx?.resolvedAt).toBe("2026-05-01T00:00:01.000Z");
|
|
expect(npmInstallCall()).toBeUndefined();
|
|
});
|
|
|
|
it("keeps integrity drift checks for exact official pins during official sync", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2",
|
|
registryVersion: "2026.5.2",
|
|
registryIntegrity: "sha512-new",
|
|
spec: "@openclaw/acpx@2026.5.2",
|
|
integrity: "sha512-old",
|
|
installerVersion: "2026.5.2",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.5.2",
|
|
});
|
|
|
|
await updatePlugin(config, "acpx", { syncOfficialPluginInstalls: true });
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/acpx",
|
|
expectedPluginId: "acpx",
|
|
expectedIntegrity: "sha512-old",
|
|
});
|
|
});
|
|
|
|
it("skips integrity checks when official sync may choose a compatible fallback", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2",
|
|
registryVersion: "2026.5.2",
|
|
registryIntegrity: "sha512-old",
|
|
registryOpenClaw: { compat: { pluginApi: ">=9999.0.0" } },
|
|
spec: "@openclaw/acpx@2026.5.2",
|
|
integrity: "sha512-old",
|
|
installerVersion: "2026.5.1",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.5.1",
|
|
});
|
|
|
|
await updatePlugin(config, "acpx", { syncOfficialPluginInstalls: true });
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/acpx",
|
|
expectedPluginId: "acpx",
|
|
expectedIntegrity: undefined,
|
|
});
|
|
});
|
|
|
|
it("keeps integrity drift checks when official latest falls back to pinned stable", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2",
|
|
registryVersion: "2026.5.3-beta.1",
|
|
registryIntegrity: "sha512-beta",
|
|
spec: "@openclaw/acpx@2026.5.2",
|
|
integrity: "sha512-old",
|
|
installerVersion: "2026.5.2",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.5.2",
|
|
});
|
|
mockNpmViewVersions(["2026.5.2", "2026.5.3-beta.1"]);
|
|
mockNpmViewMetadata({
|
|
name: "@openclaw/acpx",
|
|
version: "2026.5.2",
|
|
integrity: "sha512-old",
|
|
});
|
|
|
|
await updatePlugin(config, "acpx", { syncOfficialPluginInstalls: true });
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/acpx",
|
|
expectedPluginId: "acpx",
|
|
expectedIntegrity: "sha512-old",
|
|
});
|
|
});
|
|
|
|
it("keeps integrity drift checks for exact prerelease-only official pins", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "voice-call",
|
|
packageName: "@openclaw/voice-call",
|
|
installedVersion: "0.0.2-beta.1",
|
|
registryVersion: "0.0.2-beta.1",
|
|
registryIntegrity: "sha512-beta",
|
|
spec: "@openclaw/voice-call@0.0.2-beta.1",
|
|
integrity: "sha512-old",
|
|
installerVersion: "0.0.2-beta.1",
|
|
installerResolvedSpec: "@openclaw/voice-call@0.0.2-beta.1",
|
|
});
|
|
mockNpmViewVersions(["0.0.1-beta.1", "0.0.2-beta.1"]);
|
|
|
|
await updatePlugin(config, "voice-call", { syncOfficialPluginInstalls: true });
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/voice-call",
|
|
expectedPluginId: "voice-call",
|
|
expectedIntegrity: "sha512-old",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "keeps integrity drift checks for exact official pins during beta fallback",
|
|
fallbackMetadata: [{ version: "2026.5.2", integrity: "sha512-old" }],
|
|
installerVersion: "2026.5.2",
|
|
expectedIntegrity: "sha512-old",
|
|
},
|
|
{
|
|
name: "keeps integrity checks when beta fallback bare spec resolves to a prerelease first",
|
|
fallbackMetadata: [
|
|
{ version: "2026.5.3-beta.1", integrity: "sha512-beta" },
|
|
{ versions: ["2026.5.2", "2026.5.3-beta.1"] },
|
|
{ version: "2026.5.2", integrity: "sha512-old" },
|
|
],
|
|
installerVersion: "2026.5.2",
|
|
expectedIntegrity: "sha512-old",
|
|
},
|
|
{
|
|
name: "skips fallback integrity checks when official fallback may choose a compatible version",
|
|
fallbackMetadata: [
|
|
{
|
|
version: "2026.5.2",
|
|
integrity: "sha512-old",
|
|
openclaw: { compat: { pluginApi: ">=9999.0.0" } },
|
|
},
|
|
],
|
|
installerVersion: "2026.5.1",
|
|
expectedIntegrity: undefined,
|
|
},
|
|
])("$name", async ({ fallbackMetadata, installerVersion, expectedIntegrity }) => {
|
|
const { installPath, config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2",
|
|
registryVersion: "2026.5.3-beta.1",
|
|
registryIntegrity: "sha512-beta",
|
|
spec: "@openclaw/acpx@2026.5.2",
|
|
integrity: "sha512-old",
|
|
});
|
|
for (const metadata of fallbackMetadata) {
|
|
if ("versions" in metadata && metadata.versions) {
|
|
mockNpmViewVersions(metadata.versions);
|
|
} else {
|
|
mockNpmViewMetadata({ name: "@openclaw/acpx", ...metadata });
|
|
}
|
|
}
|
|
installPluginFromNpmSpecMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: "No matching version found for @openclaw/acpx@beta",
|
|
code: "npm_package_not_found",
|
|
})
|
|
.mockResolvedValueOnce(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "acpx",
|
|
targetDir: installPath,
|
|
version: installerVersion,
|
|
npmResolution: {
|
|
name: "@openclaw/acpx",
|
|
version: installerVersion,
|
|
resolvedSpec: `@openclaw/acpx@${installerVersion}`,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await updatePlugin(config, "acpx", {
|
|
syncOfficialPluginInstalls: true,
|
|
updateChannel: "beta",
|
|
});
|
|
|
|
expect(npmInstallCall(0)?.spec).toBe("@openclaw/acpx@beta");
|
|
expect(npmInstallCall(0)?.expectedIntegrity).toBeUndefined();
|
|
expect(npmInstallCall(1)?.spec).toBe("@openclaw/acpx");
|
|
expect(npmInstallCall(1)?.expectedIntegrity).toBe(expectedIntegrity);
|
|
});
|
|
|
|
it("keeps third-party moving npm specs when their updates resolve exact artifacts", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "lossless-claw",
|
|
packageName: "@martian-engineering/lossless-claw",
|
|
installedVersion: "0.9.0",
|
|
registryVersion: "0.9.1",
|
|
installerVersion: "0.9.1",
|
|
installerResolvedSpec: "@martian-engineering/lossless-claw@0.9.1",
|
|
});
|
|
const result = await updatePlugin(config, "lossless-claw");
|
|
|
|
expect(result.config.plugins?.installs?.["lossless-claw"]?.spec).toBe(
|
|
"@martian-engineering/lossless-claw",
|
|
);
|
|
expect(result.config.plugins?.installs?.["lossless-claw"]?.resolvedSpec).toBe(
|
|
"@martian-engineering/lossless-claw@0.9.1",
|
|
);
|
|
});
|
|
|
|
it("does not apply official beta-channel sync to third-party npm specs", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "lossless-claw",
|
|
packageName: "@martian-engineering/lossless-claw",
|
|
installedVersion: "0.9.0",
|
|
registryVersion: "0.9.1",
|
|
installerVersion: "0.9.1",
|
|
installerResolvedSpec: "@martian-engineering/lossless-claw@0.9.1",
|
|
});
|
|
await updatePlugin(config, "lossless-claw", {
|
|
syncOfficialPluginInstalls: true,
|
|
officialPluginUpdateChannel: "beta",
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@martian-engineering/lossless-claw");
|
|
});
|
|
|
|
it("does not skip trusted official default updates when latest resolves to the installed prerelease", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2-beta.2",
|
|
registryVersion: "2026.5.2-beta.2",
|
|
registryIntegrity: "sha512-beta",
|
|
registryShasum: "beta",
|
|
spec: "@openclaw/acpx@2026.5.2-beta.2",
|
|
integrity: "sha512-beta",
|
|
shasum: "beta",
|
|
installerVersion: "2026.5.2",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.5.2",
|
|
});
|
|
const result = await updatePlugin(config, "acpx", { syncOfficialPluginInstalls: true });
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/acpx");
|
|
expect(npmInstallCall()?.expectedIntegrity).toBeUndefined();
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("acpx");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
expect(result.outcomes[0]?.pluginId).toBe("acpx");
|
|
expect(result.outcomes[0]?.status).toBe("updated");
|
|
expect(result.outcomes[0]?.currentVersion).toBe("2026.5.2-beta.2");
|
|
expect(result.outcomes[0]?.nextVersion).toBe("2026.5.2");
|
|
});
|
|
|
|
it("updates trusted official npm plugins when latest resolves to a stable correction release", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.3",
|
|
registryVersion: "2026.5.3-1",
|
|
registryIntegrity: "sha512-correction",
|
|
registryShasum: "correction",
|
|
installerVersion: "2026.5.3-1",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.5.3-1",
|
|
});
|
|
const result = await updatePlugin(config, "acpx");
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/acpx");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("acpx");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
expect(result.outcomes[0]?.pluginId).toBe("acpx");
|
|
expect(result.outcomes[0]?.status).toBe("updated");
|
|
expect(result.outcomes[0]?.currentVersion).toBe("2026.5.3");
|
|
expect(result.outcomes[0]?.nextVersion).toBe("2026.5.3-1");
|
|
});
|
|
|
|
it("does not trust official npm updates when the install record package mismatches", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@vendor/acpx-fork",
|
|
installedVersion: "1.0.0",
|
|
registryVersion: "1.0.1",
|
|
installerVersion: "1.0.1",
|
|
});
|
|
await updatePlugin(config, "acpx");
|
|
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).not.toBe(true);
|
|
});
|
|
|
|
it("skips npm reinstall and config rewrite when the installed artifact is unchanged", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "lossless-claw",
|
|
packageName: "@martian-engineering/lossless-claw",
|
|
installedVersion: "0.9.0",
|
|
registryVersion: "0.9.0",
|
|
registryIntegrity: "sha512-same",
|
|
registryShasum: "same",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
|
|
|
|
const result = await updatePlugin(config, "lossless-claw");
|
|
|
|
expect(npmViewCall()?.[0]).toEqual([
|
|
"npm",
|
|
"view",
|
|
"@martian-engineering/lossless-claw",
|
|
"name",
|
|
"version",
|
|
"dist.integrity",
|
|
"dist.shasum",
|
|
"openclaw",
|
|
"--json",
|
|
]);
|
|
if (npmViewCall()?.[1] === undefined) {
|
|
throw new Error("Expected npm view command options");
|
|
}
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "lossless-claw",
|
|
status: "unchanged",
|
|
currentVersion: "0.9.0",
|
|
nextVersion: "0.9.0",
|
|
message: "lossless-claw is up to date (0.9.0).",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "latest",
|
|
updateChannel: undefined,
|
|
registrySpec: "@acme/demo",
|
|
registryVersion: "1.2.4",
|
|
overrideSpec: "@acme/demo@latest",
|
|
},
|
|
{
|
|
name: "beta",
|
|
updateChannel: "beta" as const,
|
|
registrySpec: "@acme/demo@beta",
|
|
registryVersion: "1.3.0-beta.1",
|
|
overrideSpec: "@acme/demo@beta",
|
|
},
|
|
])(
|
|
"reports newer $name releases for exact-pinned installed records instead of claiming up to date",
|
|
async ({ updateChannel, registrySpec, registryVersion, overrideSpec }) => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "demo",
|
|
packageName: "@acme/demo",
|
|
installedVersion: "1.2.3",
|
|
registryVersion: "1.2.3",
|
|
registryIntegrity: "sha512-same",
|
|
registryShasum: "same",
|
|
spec: "@acme/demo@1.2.3",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
installedAt: "2026-07-01T00:00:00.000Z",
|
|
resolvedAt: "2026-07-01T00:00:01.000Z",
|
|
});
|
|
mockNpmViewMetadata({
|
|
name: "@acme/demo",
|
|
version: registryVersion,
|
|
});
|
|
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
|
|
|
|
const result = await updatePlugin(config, "demo", updateChannel ? { updateChannel } : {});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(runCommandWithTimeoutMock.mock.calls).toHaveLength(2);
|
|
expect(runCommandWithTimeoutMock.mock.calls[1]?.[0]).toEqual([
|
|
"npm",
|
|
"view",
|
|
registrySpec,
|
|
"name",
|
|
"version",
|
|
"dist.integrity",
|
|
"dist.shasum",
|
|
"openclaw",
|
|
"--json",
|
|
]);
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "unchanged",
|
|
currentVersion: "1.2.3",
|
|
nextVersion: registryVersion,
|
|
message:
|
|
`demo is pinned to @acme/demo@1.2.3 (installed 1.2.3); ` +
|
|
`registry ${updateChannel === "beta" ? "beta" : "latest"} resolves to ${registryVersion}. ` +
|
|
`Pass \`openclaw plugins update ${overrideSpec}\` to follow that registry line.`,
|
|
},
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("reports a newer latest release when the beta line for an exact pin is unavailable", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "demo",
|
|
packageName: "@acme/demo",
|
|
installedVersion: "1.2.3",
|
|
registryVersion: "1.2.3",
|
|
registryIntegrity: "sha512-same",
|
|
registryShasum: "same",
|
|
spec: "@acme/demo@1.2.3",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "npm error code E404",
|
|
});
|
|
mockNpmViewMetadata({
|
|
name: "@acme/demo",
|
|
version: "1.2.4",
|
|
});
|
|
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
|
|
|
|
const result = await updatePlugin(config, "demo", { updateChannel: "beta" });
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(runCommandWithTimeoutMock.mock.calls).toHaveLength(3);
|
|
expect(runCommandWithTimeoutMock.mock.calls[2]?.[0]).toEqual([
|
|
"npm",
|
|
"view",
|
|
"@acme/demo",
|
|
"name",
|
|
"version",
|
|
"dist.integrity",
|
|
"dist.shasum",
|
|
"openclaw",
|
|
"--json",
|
|
]);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "unchanged",
|
|
currentVersion: "1.2.3",
|
|
nextVersion: "1.2.4",
|
|
message:
|
|
"demo is pinned to @acme/demo@1.2.3 (installed 1.2.3); registry latest resolves to 1.2.4. " +
|
|
"Pass `openclaw plugins update @acme/demo@latest` to follow that registry line.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "does not skip unchanged npm plugins when package metadata requires a newer plugin API",
|
|
compatibility: { compat: { pluginApi: ">=2026.5.28-beta.4" } },
|
|
assertFullOutcome: true,
|
|
},
|
|
{
|
|
name: "does not skip unchanged npm plugins when package metadata requires a newer host",
|
|
compatibility: { install: { minHostVersion: ">=2026.5.28-beta.4" } },
|
|
assertFullOutcome: false,
|
|
},
|
|
] as const)("$name", async ({ compatibility, assertFullOutcome }) => {
|
|
vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3");
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "msteams",
|
|
packageName: "@openclaw/msteams",
|
|
installedVersion: "2026.5.28-beta.4",
|
|
registryVersion: "2026.5.28-beta.4",
|
|
registryIntegrity: "sha512-newer",
|
|
registryShasum: "newer",
|
|
registryOpenClaw: { extensions: ["./dist/index.js"], ...compatibility },
|
|
integrity: "sha512-newer",
|
|
shasum: "newer",
|
|
installerVersion: "2026.5.28-beta.3",
|
|
installerResolvedSpec: "@openclaw/msteams@2026.5.28-beta.3",
|
|
});
|
|
|
|
const result = await updatePlugin(config, "msteams");
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/msteams");
|
|
expect(npmInstallCall()?.mode).toBe("update");
|
|
if (assertFullOutcome) {
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("msteams");
|
|
}
|
|
expect(result.changed).toBe(true);
|
|
expectRecordFields(result.config.plugins?.installs?.msteams, {
|
|
source: "npm",
|
|
version: "2026.5.28-beta.3",
|
|
resolvedName: "@openclaw/msteams",
|
|
resolvedVersion: "2026.5.28-beta.3",
|
|
resolvedSpec: "@openclaw/msteams@2026.5.28-beta.3",
|
|
});
|
|
if (assertFullOutcome) {
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "msteams",
|
|
status: "updated",
|
|
currentVersion: "2026.5.28-beta.4",
|
|
nextVersion: "2026.5.28-beta.3",
|
|
message: "Updated msteams: 2026.5.28-beta.4 -> 2026.5.28-beta.3.",
|
|
},
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("repairs missing openclaw peer links before skipping unchanged npm plugins", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.3",
|
|
peerDependencies: { openclaw: ">=2026.5.3" },
|
|
});
|
|
mockNpmViewMetadata({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.3",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "codex",
|
|
targetDir: installPath,
|
|
version: "2026.5.3",
|
|
npmResolution: {
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.3",
|
|
resolvedSpec: "@openclaw/codex@2026.5.3",
|
|
},
|
|
}),
|
|
);
|
|
const config: OpenClawConfig = {
|
|
plugins: {
|
|
installs: {
|
|
codex: {
|
|
source: "npm",
|
|
spec: "@openclaw/codex",
|
|
installPath,
|
|
resolvedName: "@openclaw/codex",
|
|
resolvedVersion: "2026.5.3",
|
|
resolvedSpec: "@openclaw/codex@2026.5.3",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await updatePlugin(config, "codex");
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/codex");
|
|
expect(npmInstallCall()?.mode).toBe("update");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("codex");
|
|
expect(result.changed).toBe(true);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "codex",
|
|
status: "unchanged",
|
|
currentVersion: "2026.5.3",
|
|
nextVersion: "2026.5.3",
|
|
message: "codex already at 2026.5.3.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("skips unchanged npm plugins when the openclaw peer link already resolves", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.3",
|
|
peerDependencies: { openclaw: ">=2026.5.3" },
|
|
});
|
|
fs.mkdirSync(path.join(installPath, "node_modules", "openclaw"), { recursive: true });
|
|
mockNpmViewMetadata({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.3",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
installs: {
|
|
codex: {
|
|
source: "npm",
|
|
spec: "@openclaw/codex",
|
|
installPath,
|
|
resolvedName: "@openclaw/codex",
|
|
resolvedVersion: "2026.5.3",
|
|
resolvedSpec: "@openclaw/codex@2026.5.3",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["codex"],
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "codex",
|
|
status: "unchanged",
|
|
currentVersion: "2026.5.3",
|
|
nextVersion: "2026.5.3",
|
|
message: "codex is up to date (2026.5.3).",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("repairs openclaw peer links after batch npm updates prune earlier plugin links", async () => {
|
|
const plugins = [
|
|
{ pluginId: "brave", packageName: "@openclaw/brave-plugin" },
|
|
{ pluginId: "codex", packageName: "@openclaw/codex" },
|
|
{ pluginId: "discord", packageName: "@openclaw/discord" },
|
|
];
|
|
const { installPaths, peerLinkPath, linkPeer } = createOpenClawPeerLinkFixtures(plugins);
|
|
for (const { packageName } of plugins) {
|
|
mockNpmViewMetadata({
|
|
name: packageName,
|
|
version: "2026.5.4",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
}
|
|
installPluginFromNpmSpecMock.mockImplementation(
|
|
(params: { expectedPluginId?: string; spec: string }) => {
|
|
const pluginId = requireExpectedPluginId(params);
|
|
for (const { pluginId: installedPluginId } of plugins) {
|
|
fs.rmSync(peerLinkPath(installedPluginId), { recursive: true, force: true });
|
|
}
|
|
linkPeer(pluginId);
|
|
const packageName = requirePluginPackageName(plugins, pluginId);
|
|
return Promise.resolve(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId,
|
|
targetDir: installPaths[pluginId],
|
|
version: "2026.5.4",
|
|
npmResolution: {
|
|
name: packageName,
|
|
version: "2026.5.4",
|
|
resolvedSpec: `${packageName}@2026.5.4`,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createPeerLinkInstallConfig({ plugins, installPaths }),
|
|
pluginIds: plugins.map((plugin) => plugin.pluginId),
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(3);
|
|
for (const { pluginId } of plugins) {
|
|
expect(fs.existsSync(peerLinkPath(pluginId))).toBe(true);
|
|
}
|
|
expect(result.outcomes).toEqual(
|
|
plugins.map(({ pluginId }) => ({
|
|
pluginId,
|
|
status: "unchanged",
|
|
currentVersion: "2026.5.4",
|
|
nextVersion: "2026.5.4",
|
|
message: `${pluginId} already at 2026.5.4.`,
|
|
})),
|
|
);
|
|
});
|
|
|
|
it("repairs sibling openclaw peer links after a targeted npm update prunes the shared install tree", async () => {
|
|
const plugins = [
|
|
{ pluginId: "brave", packageName: "@openclaw/brave-plugin" },
|
|
{ pluginId: "codex", packageName: "@openclaw/codex" },
|
|
{ pluginId: "discord", packageName: "@openclaw/discord" },
|
|
];
|
|
const { installPaths, peerLinkPath, linkPeer } = createOpenClawPeerLinkFixtures(plugins);
|
|
linkPeer("brave");
|
|
linkPeer("discord");
|
|
mockNpmViewMetadata({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.4",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
installPluginFromNpmSpecMock.mockImplementation(() => {
|
|
for (const { pluginId } of plugins) {
|
|
fs.rmSync(peerLinkPath(pluginId), { recursive: true, force: true });
|
|
}
|
|
linkPeer("codex");
|
|
return Promise.resolve(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "codex",
|
|
targetDir: installPaths.codex,
|
|
version: "2026.5.4",
|
|
npmResolution: {
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.4",
|
|
resolvedSpec: "@openclaw/codex@2026.5.4",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
await updateNpmInstalledPlugins({
|
|
config: createPeerLinkInstallConfig({ plugins, installPaths }),
|
|
pluginIds: ["codex"],
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
|
for (const { pluginId } of plugins) {
|
|
expect(fs.existsSync(peerLinkPath(pluginId))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("continues repairing sibling openclaw peer links after one recorded npm install cannot be relinked", async () => {
|
|
const plugins = [
|
|
{ pluginId: "brave", packageName: "@openclaw/brave-plugin" },
|
|
{ pluginId: "codex", packageName: "@openclaw/codex" },
|
|
];
|
|
const { installPaths, peerLinkPath, linkPeer } = createOpenClawPeerLinkFixtures(plugins);
|
|
const brokenInstallPath = createInstalledPackageDir({
|
|
name: "@openclaw/broken-plugin",
|
|
version: "2026.5.4",
|
|
peerDependencies: { openclaw: ">=2026.5.4" },
|
|
});
|
|
fs.writeFileSync(path.join(brokenInstallPath, "node_modules"), "not a directory");
|
|
linkPeer("brave");
|
|
mockNpmViewMetadata({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.4",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
installPluginFromNpmSpecMock.mockImplementation(() => {
|
|
for (const { pluginId } of plugins) {
|
|
fs.rmSync(peerLinkPath(pluginId), { recursive: true, force: true });
|
|
}
|
|
linkPeer("codex");
|
|
return Promise.resolve(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "codex",
|
|
targetDir: installPaths.codex,
|
|
version: "2026.5.4",
|
|
npmResolution: {
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.4",
|
|
resolvedSpec: "@openclaw/codex@2026.5.4",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
const warnMessages: string[] = [];
|
|
|
|
await updateNpmInstalledPlugins({
|
|
config: createPeerLinkInstallConfig({
|
|
plugins,
|
|
installPaths,
|
|
extraInstalls: {
|
|
broken: {
|
|
source: "npm",
|
|
spec: "@openclaw/broken-plugin",
|
|
installPath: brokenInstallPath,
|
|
resolvedName: "@openclaw/broken-plugin",
|
|
resolvedVersion: "2026.5.4",
|
|
resolvedSpec: "@openclaw/broken-plugin@2026.5.4",
|
|
},
|
|
},
|
|
}),
|
|
pluginIds: ["codex"],
|
|
logger: { warn: (message) => warnMessages.push(message) },
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
|
expect(fs.existsSync(peerLinkPath("brave"))).toBe(true);
|
|
expect(fs.existsSync(peerLinkPath("codex"))).toBe(true);
|
|
expect(warnMessages).toEqual([
|
|
`Could not repair openclaw peer link for "broken" at ${brokenInstallPath}: Skipping openclaw peerDependency link because ${path.join(brokenInstallPath, "node_modules")} is not a real directory.`,
|
|
]);
|
|
});
|
|
|
|
it("refreshes legacy npm install records before skipping unchanged artifacts", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
});
|
|
mockNpmViewMetadata({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "lossless-claw",
|
|
targetDir: installPath,
|
|
version: "0.9.0",
|
|
npmResolution: {
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createNpmInstallConfig({
|
|
pluginId: "lossless-claw",
|
|
spec: "@martian-engineering/lossless-claw",
|
|
installPath,
|
|
}),
|
|
pluginIds: ["lossless-claw"],
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
|
expect(result.changed).toBe(true);
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "lossless-claw",
|
|
status: "unchanged",
|
|
currentVersion: "0.9.0",
|
|
nextVersion: "0.9.0",
|
|
});
|
|
expectRecordFields(result.config.plugins?.installs?.["lossless-claw"], {
|
|
source: "npm",
|
|
spec: "@martian-engineering/lossless-claw",
|
|
resolvedName: "@martian-engineering/lossless-claw",
|
|
resolvedVersion: "0.9.0",
|
|
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
|
});
|
|
});
|
|
|
|
it("expands home-relative install paths before checking installed npm versions", async () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-home-"));
|
|
tempDirs.push(home);
|
|
const installPath = path.join(home, ".openclaw", "extensions", "lossless-claw");
|
|
fs.mkdirSync(installPath, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(installPath, "package.json"),
|
|
JSON.stringify({ name: "@martian-engineering/lossless-claw", version: "0.9.0" }),
|
|
);
|
|
mockNpmViewMetadata({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
});
|
|
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
|
|
|
|
const result = await withEnvAsync({ HOME: home }, () =>
|
|
updateNpmInstalledPlugins({
|
|
config: createNpmInstallConfig({
|
|
pluginId: "lossless-claw",
|
|
spec: "@martian-engineering/lossless-claw",
|
|
installPath: "~/.openclaw/extensions/lossless-claw",
|
|
resolvedName: "@martian-engineering/lossless-claw",
|
|
resolvedVersion: "0.9.0",
|
|
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
|
integrity: "sha512-same",
|
|
shasum: "same",
|
|
}),
|
|
pluginIds: ["lossless-claw"],
|
|
}),
|
|
);
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.outcomes).toHaveLength(1);
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "lossless-claw",
|
|
status: "unchanged",
|
|
currentVersion: "0.9.0",
|
|
});
|
|
});
|
|
|
|
it("falls through to npm reinstall when the recorded integrity differs", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "lossless-claw",
|
|
packageName: "@martian-engineering/lossless-claw",
|
|
installedVersion: "0.9.0",
|
|
registryVersion: "0.9.0",
|
|
registryIntegrity: "sha512-new",
|
|
integrity: "sha512-old",
|
|
installerVersion: "0.9.0",
|
|
installerResolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
|
});
|
|
const result = await updatePlugin(config, "lossless-claw");
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
|
expect(result.changed).toBe(true);
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "lossless-claw",
|
|
status: "unchanged",
|
|
currentVersion: "0.9.0",
|
|
nextVersion: "0.9.0",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "falls through to npm reinstall when metadata probing fails for valid specs",
|
|
spec: "@martian-engineering/lossless-claw",
|
|
fallsBack: true,
|
|
},
|
|
{
|
|
name: "records range metadata probing failures without falling through to npm reinstall",
|
|
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
|
fallsBack: false,
|
|
},
|
|
] as const)("$name", async ({ spec, fallsBack }) => {
|
|
const warn = vi.fn();
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
});
|
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "registry timeout",
|
|
});
|
|
if (fallsBack) {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "lossless-claw",
|
|
targetDir: installPath,
|
|
version: "0.9.0",
|
|
}),
|
|
);
|
|
}
|
|
const result = await updatePlugin(
|
|
createNpmInstallConfig({
|
|
pluginId: "lossless-claw",
|
|
spec,
|
|
installPath,
|
|
}),
|
|
"lossless-claw",
|
|
{ logger: { warn } },
|
|
);
|
|
|
|
if (fallsBack) {
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"Could not check lossless-claw before update; falling back to installer path: npm view failed: registry timeout",
|
|
);
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
|
} else {
|
|
expect(warn).not.toHaveBeenCalled();
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "lossless-claw",
|
|
status: "error",
|
|
message: "Failed to check lossless-claw: npm view failed: registry timeout",
|
|
},
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("defers installed payload validation until metadata probing fails", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
runnable: true,
|
|
});
|
|
mockNpmViewMetadata({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createNpmInstallConfig({
|
|
pluginId: "lossless-claw",
|
|
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
|
installPath,
|
|
resolvedName: "@martian-engineering/lossless-claw",
|
|
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
|
resolvedVersion: "0.9.0",
|
|
}),
|
|
pluginIds: ["lossless-claw"],
|
|
disableOnFailure: true,
|
|
});
|
|
|
|
expect(result.outcomes[0]?.status).toBe("unchanged");
|
|
expect(validatePackageExtensionEntriesForInstallMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves healthy plugin state when metadata probing fails before replacement", async () => {
|
|
const warn = vi.fn();
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
runnable: true,
|
|
});
|
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "registry timeout",
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
allow: ["lossless-claw", "keep"],
|
|
deny: ["lossless-claw", "blocked"],
|
|
slots: {
|
|
memory: "lossless-claw",
|
|
contextEngine: "lossless-claw",
|
|
},
|
|
entries: {
|
|
"lossless-claw": {
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
installs: {
|
|
"lossless-claw": {
|
|
source: "npm",
|
|
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
|
installPath,
|
|
resolvedName: "@martian-engineering/lossless-claw",
|
|
resolvedVersion: "0.9.0",
|
|
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["lossless-claw"],
|
|
disableOnFailure: true,
|
|
logger: { warn },
|
|
});
|
|
|
|
expect(warn).not.toHaveBeenCalled();
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config.plugins?.entries?.["lossless-claw"]).toEqual({
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.config.plugins?.allow).toEqual(["lossless-claw", "keep"]);
|
|
expect(result.config.plugins?.deny).toEqual(["lossless-claw", "blocked"]);
|
|
expect(result.config.plugins?.slots).toEqual({
|
|
memory: "lossless-claw",
|
|
contextEngine: "lossless-claw",
|
|
});
|
|
expect(validatePackageExtensionEntriesForInstallMock).toHaveBeenCalledTimes(1);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "lossless-claw",
|
|
status: "error",
|
|
message: "Failed to check lossless-claw: npm view failed: registry timeout",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("disables a corrupt installed payload when metadata probing also fails", async () => {
|
|
const warn = vi.fn();
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@martian-engineering/lossless-claw",
|
|
version: "0.9.0",
|
|
runnable: true,
|
|
});
|
|
fs.rmSync(path.join(installPath, "index.js"));
|
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "registry timeout",
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
allow: ["lossless-claw", "keep"],
|
|
deny: ["lossless-claw", "blocked"],
|
|
slots: {
|
|
memory: "lossless-claw",
|
|
contextEngine: "lossless-claw",
|
|
},
|
|
entries: {
|
|
"lossless-claw": {
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
installs: {
|
|
"lossless-claw": {
|
|
source: "npm",
|
|
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
|
installPath,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["lossless-claw"],
|
|
disableOnFailure: true,
|
|
logger: { warn },
|
|
});
|
|
|
|
const message =
|
|
'Disabled "lossless-claw" after plugin update failure; OpenClaw will continue without it. Failed to check lossless-claw: npm view failed: registry timeout';
|
|
expect(warn).toHaveBeenCalledWith(message);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.entries?.["lossless-claw"]).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.config.plugins?.allow).toEqual(["lossless-claw", "keep"]);
|
|
expect(result.config.plugins?.deny).toEqual(["lossless-claw", "blocked"]);
|
|
expect(result.config.plugins?.slots).toEqual({
|
|
memory: "memory-core",
|
|
contextEngine: "legacy",
|
|
});
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "lossless-claw",
|
|
status: "skipped",
|
|
message,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("continues the plugin sweep when deferred payload validation throws", async () => {
|
|
const warn = vi.fn();
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@acme/demo",
|
|
version: "1.0.0",
|
|
runnable: true,
|
|
});
|
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: "registry timeout",
|
|
});
|
|
validatePackageExtensionEntriesForInstallMock.mockImplementationOnce(() => {
|
|
throw new Error("permission denied");
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
entries: { demo: { enabled: true } },
|
|
installs: {
|
|
demo: {
|
|
source: "npm",
|
|
spec: "@acme/demo@^1.0.0",
|
|
installPath,
|
|
},
|
|
local: {
|
|
source: "path",
|
|
installPath: "/tmp/local",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["demo", "local"],
|
|
disableOnFailure: true,
|
|
logger: { warn },
|
|
});
|
|
|
|
expect(result.config.plugins?.entries?.demo?.enabled).toBe(false);
|
|
expect(result.outcomes.map(({ pluginId }) => pluginId)).toEqual(["demo", "local"]);
|
|
});
|
|
|
|
it("disables a missing plugin payload when metadata probing also fails", async () => {
|
|
const warn = vi.fn();
|
|
const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-missing-"));
|
|
tempDirs.push(installPath);
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "npm view failed: registry timeout",
|
|
code: "npm_metadata_failure",
|
|
});
|
|
const config = {
|
|
plugins: {
|
|
allow: ["demo", "other"],
|
|
deny: ["demo", "blocked"],
|
|
slots: { memory: "demo" },
|
|
entries: {
|
|
demo: {
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
installs: {
|
|
demo: {
|
|
source: "npm" as const,
|
|
spec: "@acme/demo",
|
|
installPath,
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const result = await updatePlugin(config, "demo", {
|
|
disableOnFailure: true,
|
|
logger: { warn },
|
|
});
|
|
|
|
const message =
|
|
'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: npm view failed: registry timeout';
|
|
expect(warn).toHaveBeenCalledWith(message);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.entries?.demo).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.config.plugins?.allow).toEqual(["demo", "other"]);
|
|
expect(result.config.plugins?.deny).toEqual(["demo", "blocked"]);
|
|
expect(result.config.plugins?.slots?.memory).toBe("memory-core");
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "skipped",
|
|
message,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
source: "npm",
|
|
config: createDisabledPluginConfig({
|
|
source: "npm",
|
|
spec: "@acme/demo",
|
|
installPath: "/tmp/demo",
|
|
resolvedName: "@acme/demo",
|
|
}),
|
|
},
|
|
{
|
|
source: "ClawHub",
|
|
config: createDisabledPluginConfig({
|
|
source: "clawhub",
|
|
spec: "clawhub:demo",
|
|
installPath: "/tmp/demo",
|
|
clawhubUrl: "https://clawhub.ai",
|
|
clawhubPackage: "demo",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
}),
|
|
},
|
|
{
|
|
source: "marketplace",
|
|
config: createDisabledPluginConfig({
|
|
source: "marketplace",
|
|
installPath: "/tmp/demo",
|
|
marketplaceSource: "acme/plugins",
|
|
marketplacePlugin: "demo",
|
|
}),
|
|
},
|
|
])("skips disabled $source installs before update network calls", async ({ config }) => {
|
|
installPluginFromNpmSpecMock.mockRejectedValue(new Error("npm installer should not run"));
|
|
installPluginFromClawHubMock.mockRejectedValue(new Error("ClawHub installer should not run"));
|
|
installPluginFromMarketplaceMock.mockRejectedValue(
|
|
new Error("marketplace installer should not run"),
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config,
|
|
skipDisabledPlugins: true,
|
|
});
|
|
|
|
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(installPluginFromClawHubMock).not.toHaveBeenCalled();
|
|
expect(installPluginFromMarketplaceMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.config.plugins?.installs?.demo).toEqual(config.plugins?.installs?.demo);
|
|
expect(result.config.plugins?.entries?.demo).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "skipped",
|
|
message: 'Skipping "demo" (disabled in config).',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("updates disabled trusted official npm installs from the channel spec when requested", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.3",
|
|
});
|
|
mockNpmViewMetadata({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.4",
|
|
integrity: "sha512-next",
|
|
shasum: "next",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "codex",
|
|
targetDir: installPath,
|
|
version: "2026.5.4",
|
|
npmResolution: {
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.4",
|
|
resolvedSpec: "@openclaw/codex@2026.5.4",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
codex: {
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
installs: {
|
|
codex: {
|
|
source: "npm",
|
|
spec: "@openclaw/codex@2026.5.3",
|
|
installPath,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
skipDisabledPlugins: true,
|
|
syncOfficialPluginInstalls: true,
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/codex");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("codex");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.entries?.codex).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
expectRecordFields(result.config.plugins?.installs?.codex, {
|
|
source: "npm",
|
|
spec: "@openclaw/codex@2026.5.4",
|
|
version: "2026.5.4",
|
|
resolvedName: "@openclaw/codex",
|
|
resolvedVersion: "2026.5.4",
|
|
resolvedSpec: "@openclaw/codex@2026.5.4",
|
|
});
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "codex",
|
|
status: "updated",
|
|
currentVersion: "2026.5.3",
|
|
nextVersion: "2026.5.4",
|
|
});
|
|
});
|
|
|
|
it("preserves exact official npm pins when official install sync is not requested", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "codex",
|
|
packageName: "@openclaw/codex",
|
|
installedVersion: "2026.5.28",
|
|
spec: "@openclaw/codex@2026.5.28",
|
|
installerVersion: "2026.5.28",
|
|
installerResolvedSpec: "@openclaw/codex@2026.5.28",
|
|
});
|
|
const result = await updatePlugin(config, "codex", { dryRun: true });
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/codex@2026.5.28");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("codex");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
expect(result.changed).toBe(false);
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "codex",
|
|
status: "unchanged",
|
|
currentVersion: "2026.5.28",
|
|
nextVersion: "2026.5.28",
|
|
});
|
|
});
|
|
|
|
it("reinstalls missing exact official npm pins without official install sync", async () => {
|
|
const extensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-plugin-"));
|
|
tempDirs.push(extensionsDir);
|
|
const installPath = path.join(extensionsDir, "codex");
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "codex",
|
|
targetDir: installPath,
|
|
version: "2026.5.28",
|
|
npmResolution: {
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.28",
|
|
resolvedSpec: "@openclaw/codex@2026.5.28",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createNpmInstallConfig({
|
|
pluginId: "codex",
|
|
spec: "@openclaw/codex@2026.5.28",
|
|
installPath,
|
|
resolvedName: "@openclaw/codex",
|
|
resolvedSpec: "@openclaw/codex@2026.5.28",
|
|
resolvedVersion: "2026.5.28",
|
|
}),
|
|
pluginIds: ["codex"],
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/codex@2026.5.28");
|
|
expect(npmInstallCall()?.extensionsDir).toBe(extensionsDir);
|
|
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
|
|
expectRecordFields(result.config.plugins?.installs?.codex, {
|
|
source: "npm",
|
|
spec: "@openclaw/codex@2026.5.28",
|
|
installPath,
|
|
version: "2026.5.28",
|
|
resolvedName: "@openclaw/codex",
|
|
resolvedSpec: "@openclaw/codex@2026.5.28",
|
|
resolvedVersion: "2026.5.28",
|
|
});
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "codex",
|
|
status: "updated",
|
|
nextVersion: "2026.5.28",
|
|
});
|
|
});
|
|
|
|
it("keeps integrity checks when official sync repairs missing exact npm pins", async () => {
|
|
const extensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-plugin-"));
|
|
tempDirs.push(extensionsDir);
|
|
const installPath = path.join(extensionsDir, "codex");
|
|
mockNpmViewMetadata({
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.28",
|
|
integrity: "sha512-old",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "codex",
|
|
targetDir: installPath,
|
|
version: "2026.5.28",
|
|
npmResolution: {
|
|
name: "@openclaw/codex",
|
|
version: "2026.5.28",
|
|
resolvedSpec: "@openclaw/codex@2026.5.28",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await updateNpmInstalledPlugins({
|
|
config: createNpmInstallConfig({
|
|
pluginId: "codex",
|
|
spec: "@openclaw/codex@2026.5.28",
|
|
installPath,
|
|
resolvedName: "@openclaw/codex",
|
|
resolvedSpec: "@openclaw/codex@2026.5.28",
|
|
resolvedVersion: "2026.5.28",
|
|
integrity: "sha512-old",
|
|
}),
|
|
pluginIds: ["codex"],
|
|
syncOfficialPluginInstalls: true,
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/codex");
|
|
expect(npmInstallCall()?.expectedIntegrity).toBe("sha512-old");
|
|
});
|
|
|
|
it("keeps third-party exact pinned npm specs pinned during official install sync", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "demo",
|
|
packageName: "@acme/demo",
|
|
installedVersion: "1.2.3",
|
|
spec: "@acme/demo@1.2.3",
|
|
installerVersion: "1.2.3",
|
|
});
|
|
await updatePlugin(config, "demo", {
|
|
dryRun: true,
|
|
syncOfficialPluginInstalls: true,
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@acme/demo@1.2.3");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("demo");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "uses exact npm spec selectors as dry-run target versions when probes omit metadata",
|
|
targetVersion: "1.2.4",
|
|
status: "updated",
|
|
message: "Would update demo: 1.2.3 -> 1.2.4.",
|
|
},
|
|
{
|
|
name: "keeps exact npm dry-runs unchanged when probe metadata is absent but spec matches",
|
|
targetVersion: "1.2.3",
|
|
status: "unchanged",
|
|
message: "demo is up to date (1.2.3).",
|
|
},
|
|
] as const)("$name", async ({ targetVersion, status, message }) => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@acme/demo",
|
|
version: "1.2.3",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: installPath,
|
|
extensions: ["index.ts"],
|
|
});
|
|
|
|
const result = await updatePlugin(
|
|
createNpmInstallConfig({
|
|
pluginId: "demo",
|
|
spec: `@acme/demo@${targetVersion}`,
|
|
installPath,
|
|
}),
|
|
"demo",
|
|
{ dryRun: true },
|
|
);
|
|
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "demo",
|
|
status,
|
|
currentVersion: "1.2.3",
|
|
nextVersion: targetVersion,
|
|
message,
|
|
});
|
|
});
|
|
|
|
it.each(["@acme/demo@1.2.3", "@acme/demo@v1.2.3"])(
|
|
"reports newer registry default releases for exact pinned npm dry-runs from %s",
|
|
async (spec) => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "demo",
|
|
packageName: "@acme/demo",
|
|
installedVersion: "1.2.3",
|
|
registryVersion: "1.2.4",
|
|
spec,
|
|
installerVersion: "1.2.3",
|
|
installerResolvedSpec: spec,
|
|
});
|
|
const result = await updatePlugin(config, "demo", { dryRun: true });
|
|
|
|
expect(npmInstallCall()?.spec).toBe(spec);
|
|
expect(npmViewCall()?.[0]).toEqual([
|
|
"npm",
|
|
"view",
|
|
"@acme/demo",
|
|
"name",
|
|
"version",
|
|
"dist.integrity",
|
|
"dist.shasum",
|
|
"openclaw",
|
|
"--json",
|
|
]);
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId: "demo",
|
|
status: "unchanged",
|
|
currentVersion: "1.2.3",
|
|
nextVersion: "1.2.4",
|
|
message: `demo is pinned to ${spec} (installed 1.2.3); registry latest resolves to 1.2.4. Pass \`openclaw plugins update @acme/demo@latest\` to follow that registry line.`,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("updates disabled trusted official ClawHub installs through the catalog spec", async () => {
|
|
installPluginFromClawHubMock.mockResolvedValue(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId: "diagnostics-otel",
|
|
targetDir: "/tmp/diagnostics-otel",
|
|
version: "2026.5.4",
|
|
clawhubPackage: "@openclaw/diagnostics-otel",
|
|
}),
|
|
);
|
|
|
|
const config = createClawHubInstallConfig({
|
|
pluginId: "diagnostics-otel",
|
|
clawhubPackage: "@openclaw/diagnostics-otel",
|
|
spec: "clawhub:@openclaw/diagnostics-otel@2026.5.3",
|
|
});
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
...config,
|
|
plugins: {
|
|
...config.plugins,
|
|
entries: {
|
|
"diagnostics-otel": {
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
skipDisabledPlugins: true,
|
|
syncOfficialPluginInstalls: true,
|
|
});
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:@openclaw/diagnostics-otel");
|
|
expect(clawHubInstallCall()?.expectedPluginId).toBe("diagnostics-otel");
|
|
expectRecordFields(result.config.plugins?.installs?.["diagnostics-otel"], {
|
|
source: "clawhub",
|
|
spec: "clawhub:@openclaw/diagnostics-otel",
|
|
version: "2026.5.4",
|
|
clawhubPackage: "@openclaw/diagnostics-otel",
|
|
clawhubChannel: "official",
|
|
});
|
|
expect(result.config.plugins?.entries?.["diagnostics-otel"]).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
});
|
|
|
|
it("updates bare trusted official ClawHub installs through the catalog spec", async () => {
|
|
installPluginFromClawHubMock.mockResolvedValue(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId: "diagnostics-prometheus",
|
|
targetDir: "/tmp/diagnostics-prometheus",
|
|
version: "2026.5.4",
|
|
clawhubPackage: "@openclaw/diagnostics-prometheus",
|
|
}),
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
installs: {
|
|
"diagnostics-prometheus": {
|
|
source: "clawhub",
|
|
spec: "clawhub:@openclaw/diagnostics-prometheus@2026.5.3",
|
|
installPath: "/tmp/diagnostics-prometheus",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
syncOfficialPluginInstalls: true,
|
|
});
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:@openclaw/diagnostics-prometheus");
|
|
expect(clawHubInstallCall()?.expectedPluginId).toBe("diagnostics-prometheus");
|
|
expectRecordFields(result.config.plugins?.installs?.["diagnostics-prometheus"], {
|
|
source: "clawhub",
|
|
spec: "clawhub:@openclaw/diagnostics-prometheus",
|
|
version: "2026.5.4",
|
|
clawhubPackage: "@openclaw/diagnostics-prometheus",
|
|
clawhubChannel: "official",
|
|
});
|
|
});
|
|
|
|
it("keeps enabled tracked plugin update failures fatal when disabled skipping is enabled", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "registry timeout",
|
|
});
|
|
const config = {
|
|
plugins: {
|
|
entries: {
|
|
demo: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
installs: {
|
|
demo: {
|
|
source: "npm" as const,
|
|
spec: "@acme/demo",
|
|
installPath: "/tmp/demo",
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config,
|
|
skipDisabledPlugins: true,
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@acme/demo");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("demo");
|
|
expect(npmInstallCall()?.dryRun).toBe(true);
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "error",
|
|
message: "Failed to check demo: registry timeout",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("disables failed plugin activation without revoking explicit policy", async () => {
|
|
const warn = vi.fn();
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "registry timeout",
|
|
});
|
|
const config = {
|
|
plugins: {
|
|
entries: {
|
|
demo: {
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
installs: {
|
|
demo: {
|
|
source: "npm" as const,
|
|
spec: "@acme/demo",
|
|
installPath: "/tmp/demo",
|
|
},
|
|
},
|
|
allow: ["demo", "other"],
|
|
deny: ["blocked"],
|
|
slots: {
|
|
memory: "demo",
|
|
contextEngine: "demo",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config,
|
|
skipDisabledPlugins: true,
|
|
disableOnFailure: true,
|
|
logger: { warn },
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@acme/demo");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("demo");
|
|
const message =
|
|
'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: registry timeout';
|
|
expect(warn).toHaveBeenCalledWith(message);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.entries?.demo).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.config.plugins?.allow).toEqual(["demo", "other"]);
|
|
expect(result.config.plugins?.deny).toEqual(["blocked"]);
|
|
expect(result.config.plugins?.slots).toEqual({
|
|
memory: "memory-core",
|
|
contextEngine: "legacy",
|
|
});
|
|
expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "skipped",
|
|
message,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not create trust policy when disabling a failed plugin", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "registry timeout",
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
entries: { demo: { enabled: true } },
|
|
installs: {
|
|
demo: {
|
|
source: "npm",
|
|
spec: "@acme/demo",
|
|
installPath: "/tmp/demo",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["demo"],
|
|
disableOnFailure: true,
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.entries?.demo?.enabled).toBe(false);
|
|
expect(result.config.plugins?.allow).toBeUndefined();
|
|
expect(result.config.plugins?.deny).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "keeps an existing ClawHub plugin enabled when a risky update is not acknowledged",
|
|
code: "clawhub_risk_acknowledgement_required",
|
|
version: undefined,
|
|
error:
|
|
"Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.",
|
|
warning:
|
|
"╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯",
|
|
},
|
|
{
|
|
name: "keeps an existing ClawHub plugin enabled when a newer target release is blocked",
|
|
code: "clawhub_download_blocked",
|
|
version: "1.2.4",
|
|
error: "ClawHub blocked this release; update was not started.",
|
|
warning:
|
|
"╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯",
|
|
},
|
|
...["1.2.4", "1.2.3"].map((version) => ({
|
|
name: `keeps an existing ClawHub plugin enabled when ${version === "1.2.4" ? "newer" : "current"} target security data is unavailable`,
|
|
code: "clawhub_security_unavailable",
|
|
version,
|
|
error: `ClawHub release "demo@${version}" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version.`,
|
|
warning: undefined,
|
|
})),
|
|
])("$name", async ({ code, version, error, warning }) => {
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code,
|
|
...(version ? { version } : {}),
|
|
error,
|
|
...(warning ? { warning } : {}),
|
|
});
|
|
const config = createEnabledDemoClawHubInstallConfig();
|
|
|
|
const result = await updatePlugin(config, "demo", { disableOnFailure: true });
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:demo");
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.config.plugins?.entries?.demo).toEqual({
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.config.plugins?.allow).toEqual(["demo"]);
|
|
expect(result.config.plugins?.slots?.memory).toBe("demo");
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "skipped",
|
|
code,
|
|
currentVersion: "1.2.3",
|
|
...(warning ? { warning } : {}),
|
|
message: `Skipped demo ClawHub update: ${error} Existing installed plugin left unchanged.`,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not skip a risk-gated ClawHub update when the installed package is missing", async () => {
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "clawhub_risk_acknowledgement_required",
|
|
error:
|
|
"Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.",
|
|
warning:
|
|
"╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯",
|
|
});
|
|
const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-missing-"));
|
|
tempDirs.push(installPath);
|
|
const config = createClawHubInstallConfig({ installPath });
|
|
config.plugins = {
|
|
...config.plugins,
|
|
entries: {
|
|
demo: {
|
|
enabled: true,
|
|
config: { preserved: true },
|
|
},
|
|
},
|
|
allow: ["demo"],
|
|
slots: {
|
|
memory: "demo",
|
|
},
|
|
};
|
|
|
|
const result = await updatePlugin(config, "demo", { disableOnFailure: true });
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:demo");
|
|
const message =
|
|
'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo).';
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.entries?.demo).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.config.plugins?.allow).toEqual(["demo"]);
|
|
expect(result.config.plugins?.slots?.memory).toBe("memory-core");
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "skipped",
|
|
message,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("disables a blocked ClawHub plugin without changing trust policy", async () => {
|
|
const warn = vi.fn();
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "clawhub_download_blocked",
|
|
version: "1.2.3",
|
|
error: "ClawHub blocked this release; update was not started.",
|
|
warning:
|
|
"╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯",
|
|
});
|
|
const config = createEnabledDemoClawHubInstallConfig();
|
|
|
|
const result = await updatePlugin(config, "demo", {
|
|
disableOnFailure: true,
|
|
logger: { warn },
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.entries?.demo).toEqual({
|
|
enabled: false,
|
|
config: { preserved: true },
|
|
});
|
|
expect(result.config.plugins?.allow).toEqual(["demo"]);
|
|
expect(result.config.plugins?.slots).toEqual({
|
|
memory: "memory-core",
|
|
});
|
|
const message =
|
|
'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: ClawHub blocked this release; update was not started. (ClawHub clawhub:demo).';
|
|
expect(warn).toHaveBeenCalledWith(message);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "demo",
|
|
status: "skipped",
|
|
message,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("aborts exact pinned npm plugin updates on integrity drift by default", async () => {
|
|
const warn = vi.fn();
|
|
installPluginFromNpmSpecMock.mockImplementation(
|
|
async (params: {
|
|
spec: string;
|
|
onIntegrityDrift?: (drift: NpmInstallIntegrityDrift) => boolean | Promise<boolean>;
|
|
}) => {
|
|
const proceed = await params.onIntegrityDrift?.({
|
|
spec: params.spec,
|
|
expectedIntegrity: "sha512-old",
|
|
actualIntegrity: "sha512-new",
|
|
resolution: {
|
|
integrity: "sha512-new",
|
|
resolvedSpec: "@opik/opik-openclaw@0.2.5",
|
|
version: "0.2.5",
|
|
},
|
|
});
|
|
if (proceed === false) {
|
|
return {
|
|
ok: false,
|
|
error: "aborted: npm package integrity drift detected for @opik/opik-openclaw@0.2.5",
|
|
};
|
|
}
|
|
return createSuccessfulNpmUpdateResult();
|
|
},
|
|
);
|
|
|
|
const config = createNpmInstallConfig({
|
|
pluginId: "opik-openclaw",
|
|
spec: "@opik/opik-openclaw@0.2.5",
|
|
integrity: "sha512-old",
|
|
installPath: "/tmp/opik-openclaw",
|
|
});
|
|
const result = await updatePlugin(config, "opik-openclaw", { logger: { warn } });
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'Integrity drift for "opik-openclaw" (@opik/opik-openclaw@0.2.5): expected sha512-old, got sha512-new',
|
|
);
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "opik-openclaw",
|
|
status: "error",
|
|
message:
|
|
"Failed to update opik-openclaw: aborted: npm package integrity drift detected for @opik/opik-openclaw@0.2.5",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "formats package-not-found updates with a stable message",
|
|
installerResult: {
|
|
ok: false,
|
|
code: "npm_package_not_found",
|
|
error: "Package not found on npm: @openclaw/missing.",
|
|
},
|
|
config: createNpmInstallConfig({
|
|
pluginId: "missing",
|
|
spec: "@openclaw/missing",
|
|
installPath: "/tmp/missing",
|
|
}),
|
|
pluginId: "missing",
|
|
expectedMessage: "Failed to check missing: npm package not found for @openclaw/missing.",
|
|
},
|
|
{
|
|
name: "falls back to raw installer error for unknown error codes",
|
|
installerResult: {
|
|
ok: false,
|
|
code: "invalid_npm_spec",
|
|
error: "unsupported npm spec: github:evil/evil",
|
|
},
|
|
config: createNpmInstallConfig({
|
|
pluginId: "bad",
|
|
spec: "github:evil/evil",
|
|
installPath: "/tmp/bad",
|
|
}),
|
|
pluginId: "bad",
|
|
expectedMessage: "Failed to check bad: unsupported npm spec: github:evil/evil",
|
|
},
|
|
] as const)("$name", async ({ installerResult, config, pluginId, expectedMessage }) => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(installerResult);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config,
|
|
pluginIds: [pluginId],
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId,
|
|
status: "error",
|
|
message: expectedMessage,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "reuses a recorded npm dist-tag spec for id-based updates",
|
|
installerResult: {
|
|
ok: true,
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
extensions: ["index.ts"],
|
|
},
|
|
config: createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server@beta",
|
|
resolvedName: "openclaw-codex-app-server",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.3",
|
|
}),
|
|
expectedSpec: "openclaw-codex-app-server@beta",
|
|
expectedVersion: "0.2.0-beta.4",
|
|
},
|
|
{
|
|
name: "uses and persists an explicit npm spec override during updates",
|
|
installerResult: {
|
|
ok: true,
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
extensions: ["index.ts"],
|
|
npmResolution: {
|
|
name: "openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4",
|
|
},
|
|
},
|
|
config: createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server",
|
|
}),
|
|
specOverrides: {
|
|
"openclaw-codex-app-server": "openclaw-codex-app-server@beta",
|
|
},
|
|
expectedSpec: "openclaw-codex-app-server@beta",
|
|
expectedRecordSpec: "openclaw-codex-app-server@beta",
|
|
expectedVersion: "0.2.0-beta.4",
|
|
expectedResolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4",
|
|
},
|
|
] as const)(
|
|
"$name",
|
|
async ({
|
|
installerResult,
|
|
config,
|
|
specOverrides,
|
|
expectedSpec,
|
|
expectedRecordSpec,
|
|
expectedVersion,
|
|
expectedResolvedSpec,
|
|
}) => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(installerResult);
|
|
|
|
const result = await updatePlugin(
|
|
config,
|
|
"openclaw-codex-app-server",
|
|
specOverrides ? { specOverrides } : {},
|
|
);
|
|
|
|
expectNpmUpdateCall({
|
|
spec: expectedSpec,
|
|
expectedPluginId: "openclaw-codex-app-server",
|
|
});
|
|
expectCodexAppServerInstallState({
|
|
result,
|
|
spec: expectedRecordSpec ?? expectedSpec,
|
|
version: expectedVersion,
|
|
...(expectedResolvedSpec ? { resolvedSpec: expectedResolvedSpec } : {}),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves explicit official npm tag overrides during manual updates", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.5.2",
|
|
registryVersion: "2026.5.3-beta.1",
|
|
installerVersion: "2026.5.3-beta.1",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.5.3-beta.1",
|
|
});
|
|
const result = await updatePlugin(config, "acpx", {
|
|
specOverrides: { acpx: "@openclaw/acpx@beta" },
|
|
});
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/acpx@beta",
|
|
expectedPluginId: "acpx",
|
|
});
|
|
expectRecordFields(result.config.plugins?.installs?.acpx, {
|
|
spec: "@openclaw/acpx@beta",
|
|
version: "2026.5.3-beta.1",
|
|
resolvedSpec: "@openclaw/acpx@2026.5.3-beta.1",
|
|
});
|
|
});
|
|
|
|
it("tries npm beta for default npm specs on beta channel and preserves the default selector", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
npmResolution: {
|
|
name: "openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server",
|
|
}),
|
|
pluginIds: ["openclaw-codex-app-server"],
|
|
updateChannel: "beta",
|
|
});
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "openclaw-codex-app-server@beta",
|
|
expectedPluginId: "openclaw-codex-app-server",
|
|
});
|
|
expectCodexAppServerInstallState({
|
|
result,
|
|
spec: "openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4",
|
|
});
|
|
});
|
|
|
|
it("targets the exact core version for official extended-stable updates and preserves intent", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.7.21",
|
|
registryVersion: "2026.7.33",
|
|
installerVersion: "2026.7.33",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.7.33",
|
|
});
|
|
const result = await updatePlugin(config, "acpx", {
|
|
syncOfficialPluginInstalls: true,
|
|
officialPluginUpdateChannel: "extended-stable",
|
|
coreVersion: "2026.7.33",
|
|
});
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/acpx@2026.7.33",
|
|
expectedPluginId: "acpx",
|
|
});
|
|
expectRecordFields(result.config.plugins?.installs?.acpx, {
|
|
spec: "@openclaw/acpx",
|
|
version: "2026.7.33",
|
|
resolvedSpec: "@openclaw/acpx@2026.7.33",
|
|
});
|
|
});
|
|
|
|
it("preserves an explicit official pin during extended-stable updates", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.6.33",
|
|
spec: "@openclaw/acpx@2026.6.33",
|
|
installerVersion: "2026.6.33",
|
|
});
|
|
await updatePlugin(config, "acpx", {
|
|
syncOfficialPluginInstalls: true,
|
|
officialPluginUpdateChannel: "extended-stable",
|
|
coreVersion: "2026.7.33",
|
|
dryRun: true,
|
|
});
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/acpx@2026.6.33",
|
|
expectedPluginId: "acpx",
|
|
});
|
|
});
|
|
|
|
it("lets an explicit bare official spec opt a legacy pin into exact-core tracking", async () => {
|
|
const { config } = createNpmUpdateFixture({
|
|
pluginId: "acpx",
|
|
packageName: "@openclaw/acpx",
|
|
installedVersion: "2026.6.21",
|
|
registryVersion: "2026.7.33",
|
|
spec: "@openclaw/acpx@2026.6.21",
|
|
installerVersion: "2026.7.33",
|
|
installerResolvedSpec: "@openclaw/acpx@2026.7.33",
|
|
});
|
|
const result = await updatePlugin(config, "acpx", {
|
|
specOverrides: { acpx: "@openclaw/acpx" },
|
|
syncOfficialPluginInstalls: true,
|
|
officialPluginUpdateChannel: "extended-stable",
|
|
coreVersion: "2026.7.33",
|
|
});
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "@openclaw/acpx@2026.7.33",
|
|
expectedPluginId: "acpx",
|
|
});
|
|
expectRecordFields(result.config.plugins?.installs?.acpx, {
|
|
spec: "@openclaw/acpx",
|
|
version: "2026.7.33",
|
|
resolvedSpec: "@openclaw/acpx@2026.7.33",
|
|
});
|
|
});
|
|
|
|
it("falls back to the default npm spec when a beta tag is unavailable", async () => {
|
|
installPluginFromNpmSpecMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
error:
|
|
"npm ERR! code ETARGET\nnpm ERR! No matching version found for openclaw-codex-app-server@beta.",
|
|
})
|
|
.mockResolvedValueOnce(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
npmResolution: {
|
|
name: "openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.6",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const config = createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server",
|
|
});
|
|
const warnMessages: string[] = [];
|
|
const result = await updatePlugin(config, "openclaw-codex-app-server", {
|
|
updateChannel: "beta",
|
|
logger: { warn: (msg) => warnMessages.push(msg) },
|
|
});
|
|
|
|
expect(npmInstallCall(0)?.spec).toBe("openclaw-codex-app-server@beta");
|
|
expect(npmInstallCall(1)?.spec).toBe("openclaw-codex-app-server");
|
|
expect(npmInstallCall(1)?.config).toBe(config);
|
|
expect(warnMessages).toEqual([
|
|
'Plugin "openclaw-codex-app-server" has no beta npm release for openclaw-codex-app-server@beta; using openclaw-codex-app-server instead. Core update can still complete.',
|
|
]);
|
|
expectCodexAppServerInstallState({
|
|
result,
|
|
spec: "openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.6",
|
|
});
|
|
expect(result.outcomes[0]?.message).toBe(
|
|
"Updated openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback used openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).",
|
|
);
|
|
expect(result.outcomes[0]?.channelFallback).toEqual({
|
|
requestedSpec: "openclaw-codex-app-server@beta",
|
|
usedSpec: "openclaw-codex-app-server",
|
|
requestedLabel: "@beta",
|
|
usedLabel: "@latest",
|
|
reason: "unavailable",
|
|
message:
|
|
"plugin channel fallback: openclaw-codex-app-server used @latest because @beta was unavailable",
|
|
});
|
|
});
|
|
|
|
it("reports npm beta fallback as tentative during dry-run checks", async () => {
|
|
installPluginFromNpmSpecMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
error:
|
|
"npm ERR! code ETARGET\nnpm ERR! No matching version found for openclaw-codex-app-server@beta.",
|
|
})
|
|
.mockResolvedValueOnce(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
npmResolution: {
|
|
name: "openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.6",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server",
|
|
}),
|
|
pluginIds: ["openclaw-codex-app-server"],
|
|
updateChannel: "beta",
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(result.outcomes[0]?.message).toBe(
|
|
"Would update openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback would use openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).",
|
|
);
|
|
expect(result.outcomes[0]?.channelFallback?.message).toBe(
|
|
"plugin channel fallback: openclaw-codex-app-server would use @latest because @beta was unavailable",
|
|
);
|
|
});
|
|
|
|
it("falls back to the default npm spec when the beta package exists but is invalid", async () => {
|
|
installPluginFromNpmSpecMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: "Installed plugin package uses a TypeScript entry without compiled runtime output.",
|
|
})
|
|
.mockResolvedValueOnce(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
npmResolution: {
|
|
name: "openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.6",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const warnMessages: string[] = [];
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server",
|
|
}),
|
|
pluginIds: ["openclaw-codex-app-server"],
|
|
updateChannel: "beta",
|
|
logger: { warn: (msg) => warnMessages.push(msg) },
|
|
});
|
|
|
|
expect(npmInstallCall(0)?.spec).toBe("openclaw-codex-app-server@beta");
|
|
expect(npmInstallCall(1)?.spec).toBe("openclaw-codex-app-server");
|
|
expect(warnMessages).toEqual([
|
|
'Plugin "openclaw-codex-app-server" failed beta npm update for openclaw-codex-app-server@beta; using openclaw-codex-app-server instead. Core update can still complete.',
|
|
]);
|
|
expectCodexAppServerInstallState({
|
|
result,
|
|
spec: "openclaw-codex-app-server",
|
|
version: "0.2.6",
|
|
resolvedSpec: "openclaw-codex-app-server@0.2.6",
|
|
});
|
|
expect(result.outcomes[0]?.message).toBe(
|
|
"Updated openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback used openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).",
|
|
);
|
|
expect(result.outcomes[0]?.channelFallback).toMatchObject({
|
|
requestedLabel: "@beta",
|
|
usedLabel: "@latest",
|
|
reason: "failed",
|
|
message: "plugin channel fallback: openclaw-codex-app-server used @latest after @beta failed",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "reports the fallback npm spec when beta fallback also fails",
|
|
dryRun: false,
|
|
action: "update",
|
|
fallbackVerb: "used",
|
|
assertInstallerCalls: true,
|
|
},
|
|
{
|
|
name: "keeps fallback metadata when a dry-run beta fallback also fails",
|
|
dryRun: true,
|
|
action: "check",
|
|
fallbackVerb: "would use",
|
|
assertInstallerCalls: false,
|
|
},
|
|
] as const)("$name", async ({ dryRun, action, fallbackVerb, assertInstallerCalls }) => {
|
|
installPluginFromNpmSpecMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: "Installed plugin package uses a TypeScript entry without compiled runtime output.",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "npm_package_not_found",
|
|
error: "npm package not found",
|
|
});
|
|
|
|
const result = await updatePlugin(
|
|
createCodexAppServerInstallConfig({ spec: "openclaw-codex-app-server" }),
|
|
"openclaw-codex-app-server",
|
|
{ updateChannel: "beta", ...(dryRun ? { dryRun: true } : {}) },
|
|
);
|
|
|
|
if (assertInstallerCalls) {
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(2);
|
|
}
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "openclaw-codex-app-server",
|
|
status: "error",
|
|
message: `Failed to ${action} openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.`,
|
|
channelFallback: {
|
|
requestedSpec: "openclaw-codex-app-server@beta",
|
|
usedSpec: "openclaw-codex-app-server",
|
|
requestedLabel: "@beta",
|
|
usedLabel: "@latest",
|
|
reason: "failed",
|
|
message: `plugin channel fallback: openclaw-codex-app-server ${fallbackVerb} @latest after @beta failed`,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("preserves explicit npm tags when updating on the beta channel", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.0-rc.1",
|
|
}),
|
|
);
|
|
|
|
await updateNpmInstalledPlugins({
|
|
config: createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server@rc",
|
|
}),
|
|
pluginIds: ["openclaw-codex-app-server"],
|
|
updateChannel: "beta",
|
|
dryRun: true,
|
|
});
|
|
|
|
expectNpmUpdateCall({
|
|
spec: "openclaw-codex-app-server@rc",
|
|
expectedPluginId: "openclaw-codex-app-server",
|
|
});
|
|
});
|
|
|
|
it("updates ClawHub-installed plugins via recorded package metadata", async () => {
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: "/tmp/demo",
|
|
version: "1.2.4",
|
|
clawhub: {
|
|
source: "clawhub",
|
|
clawhubUrl: "https://clawhub.ai",
|
|
clawhubPackage: "demo",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
artifactKind: "npm-pack",
|
|
artifactFormat: "tgz",
|
|
npmIntegrity: "sha512-next",
|
|
npmShasum: "1".repeat(40),
|
|
npmTarballName: "demo-1.2.4.tgz",
|
|
integrity: "sha256-next",
|
|
resolvedAt: "2026-03-22T00:00:00.000Z",
|
|
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
clawpackSpecVersion: 1,
|
|
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
clawpackSize: 4096,
|
|
},
|
|
});
|
|
|
|
const config = createClawHubInstallConfig();
|
|
delete config.plugins?.installs?.demo?.clawhubPackage;
|
|
config.plugins!.installs!.demo!.resolvedSpec = "clawhub:demo@1.2.3";
|
|
delete config.plugins?.installs?.demo?.spec;
|
|
const result = await updatePlugin(config, "demo", { timeoutMs: 1_800_000 });
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:demo@1.2.3");
|
|
expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai");
|
|
expect(clawHubInstallCall()?.expectedPluginId).toBe("demo");
|
|
expect(clawHubInstallCall()?.mode).toBe("update");
|
|
expect(clawHubInstallCall()?.timeoutMs).toBe(1_800_000);
|
|
expect(withClawPackageLifecycleLeaseMock).toHaveBeenCalledWith(
|
|
{ kind: "plugin", source: "clawhub", ref: "demo" },
|
|
expect.any(Function),
|
|
{ required: true },
|
|
);
|
|
expect(markClawPackageIndependentlyOwnedMock).toHaveBeenCalledWith({
|
|
kind: "plugin",
|
|
source: "clawhub",
|
|
ref: "demo",
|
|
});
|
|
expectRecordFields(result.config.plugins?.installs?.demo, {
|
|
source: "clawhub",
|
|
spec: "clawhub:demo@1.2.3",
|
|
installPath: "/tmp/demo",
|
|
version: "1.2.4",
|
|
clawhubPackage: "demo",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
artifactKind: "npm-pack",
|
|
artifactFormat: "tgz",
|
|
npmIntegrity: "sha512-next",
|
|
npmShasum: "1".repeat(40),
|
|
npmTarballName: "demo-1.2.4.tgz",
|
|
integrity: "sha256-next",
|
|
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
clawpackSpecVersion: 1,
|
|
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
clawpackSize: 4096,
|
|
});
|
|
});
|
|
|
|
it("records a busy ClawHub lifecycle lease as one plugin update failure", async () => {
|
|
withClawPackageLifecycleLeaseMock.mockRejectedValueOnce(new Error("package busy"));
|
|
const result = await updatePlugin(createClawHubInstallConfig(), "demo");
|
|
|
|
expect(result.outcomes).toContainEqual(
|
|
expect.objectContaining({
|
|
pluginId: "demo",
|
|
status: "error",
|
|
message: expect.stringContaining("package busy"),
|
|
}),
|
|
);
|
|
expect(installPluginFromClawHubMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tries ClawHub beta for default ClawHub specs on beta channel without persisting the beta tag", async () => {
|
|
installPluginFromClawHubMock.mockResolvedValue(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId: "demo",
|
|
targetDir: "/tmp/demo",
|
|
version: "1.3.0-beta.1",
|
|
clawhubPackage: "demo",
|
|
}),
|
|
);
|
|
|
|
const result = await updatePlugin(createClawHubInstallConfig(), "demo", {
|
|
updateChannel: "beta",
|
|
});
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:demo@beta");
|
|
expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai");
|
|
expect(clawHubInstallCall()?.expectedPluginId).toBe("demo");
|
|
expectRecordFields(result.config.plugins?.installs?.demo, {
|
|
source: "clawhub",
|
|
spec: "clawhub:demo",
|
|
installPath: "/tmp/demo",
|
|
version: "1.3.0-beta.1",
|
|
clawhubPackage: "demo",
|
|
});
|
|
});
|
|
|
|
it("falls back to the default ClawHub spec when a beta release is unavailable", async () => {
|
|
installPluginFromClawHubMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "version_not_found",
|
|
error: "version not found: beta",
|
|
})
|
|
.mockResolvedValueOnce(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId: "demo",
|
|
targetDir: "/tmp/demo",
|
|
version: "1.2.4",
|
|
clawhubPackage: "demo",
|
|
}),
|
|
);
|
|
|
|
const warnMessages: string[] = [];
|
|
const result = await updatePlugin(createClawHubInstallConfig(), "demo", {
|
|
updateChannel: "beta",
|
|
logger: { warn: (msg) => warnMessages.push(msg) },
|
|
});
|
|
|
|
expect(clawHubInstallCall(0)?.spec).toBe("clawhub:demo@beta");
|
|
expect(clawHubInstallCall(1)?.spec).toBe("clawhub:demo");
|
|
expect(warnMessages).toEqual([
|
|
'Plugin "demo" has no beta ClawHub release for clawhub:demo@beta; using clawhub:demo instead. Core update can still complete.',
|
|
]);
|
|
expectRecordFields(result.config.plugins?.installs?.demo, {
|
|
source: "clawhub",
|
|
spec: "clawhub:demo",
|
|
installPath: "/tmp/demo",
|
|
version: "1.2.4",
|
|
clawhubPackage: "demo",
|
|
});
|
|
expect(result.outcomes[0]?.message).toBe(
|
|
"Updated demo: unknown -> 1.2.4. (warning: beta channel fallback used clawhub:demo because clawhub:demo@beta could not be used).",
|
|
);
|
|
});
|
|
|
|
it("does not fall back to npm for blocked official ClawHub artifact downloads", async () => {
|
|
const warnMessages: string[] = [];
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/discord",
|
|
version: "2026.5.12",
|
|
});
|
|
installPluginFromClawHubMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "clawhub_download_blocked",
|
|
error:
|
|
'ClawHub blocked artifact download for "@openclaw/discord@2026.5.16-beta.5"; install was not started. ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded.',
|
|
version: "2026.5.16-beta.5",
|
|
});
|
|
|
|
const result = await updatePlugin(
|
|
createClawHubInstallConfig({
|
|
pluginId: "discord",
|
|
installPath,
|
|
clawhubPackage: "@openclaw/discord",
|
|
}),
|
|
"discord",
|
|
{
|
|
updateChannel: "beta",
|
|
disableOnFailure: true,
|
|
logger: { warn: (msg) => warnMessages.push(msg) },
|
|
},
|
|
);
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:@openclaw/discord@beta");
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.config.plugins?.entries?.discord?.enabled).toBeUndefined();
|
|
expectRecordFields(result.config.plugins?.installs?.discord, {
|
|
source: "clawhub",
|
|
spec: "clawhub:@openclaw/discord",
|
|
installPath,
|
|
clawhubPackage: "@openclaw/discord",
|
|
});
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "discord",
|
|
status: "skipped",
|
|
code: "clawhub_download_blocked",
|
|
currentVersion: "2026.5.12",
|
|
message:
|
|
'Skipped discord ClawHub update: ClawHub blocked artifact download for "@openclaw/discord@2026.5.16-beta.5"; install was not started. ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded. Existing installed plugin left unchanged.',
|
|
},
|
|
]);
|
|
expect(warnMessages).toStrictEqual([]);
|
|
});
|
|
|
|
it("uses the default npm spec when beta ClawHub falls back before an artifact block", async () => {
|
|
const warnMessages: string[] = [];
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/discord",
|
|
version: "2026.5.12",
|
|
});
|
|
installPluginFromClawHubMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "version_not_found",
|
|
error: "version not found: beta",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "artifact_unavailable",
|
|
error: "artifact unavailable",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValueOnce(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "discord",
|
|
targetDir: "/tmp/openclaw-plugins/discord",
|
|
version: "2026.5.16",
|
|
npmResolution: {
|
|
name: "@openclaw/discord",
|
|
version: "2026.5.16",
|
|
resolvedSpec: "@openclaw/discord@2026.5.16",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await updatePlugin(
|
|
createClawHubInstallConfig({
|
|
pluginId: "discord",
|
|
installPath,
|
|
clawhubPackage: "@openclaw/discord",
|
|
}),
|
|
"discord",
|
|
{
|
|
updateChannel: "beta",
|
|
logger: { warn: (msg) => warnMessages.push(msg) },
|
|
},
|
|
);
|
|
|
|
expect(clawHubInstallCall(0)?.spec).toBe("clawhub:@openclaw/discord@beta");
|
|
expect(clawHubInstallCall(1)?.spec).toBe("clawhub:@openclaw/discord");
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/discord");
|
|
expectRecordFields(result.config.plugins?.installs?.discord, {
|
|
source: "npm",
|
|
spec: "@openclaw/discord@2026.5.16",
|
|
installPath: "/tmp/openclaw-plugins/discord",
|
|
version: "2026.5.16",
|
|
});
|
|
expect(result.outcomes[0]?.message).toBe(
|
|
"Updated discord: 2026.5.12 -> 2026.5.16. (warning: official ClawHub artifact fallback used @openclaw/discord).",
|
|
);
|
|
expect(warnMessages).toEqual([
|
|
'Plugin "discord" has no beta ClawHub release for clawhub:@openclaw/discord@beta; using clawhub:@openclaw/discord instead. Core update can still complete.',
|
|
'Plugin "discord" could not download official ClawHub artifact for clawhub:@openclaw/discord; using npm @openclaw/discord instead. Core update can still complete.',
|
|
]);
|
|
});
|
|
|
|
it("uses exact-core npm when an official ClawHub install falls back on extended-stable", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/discord",
|
|
version: "2026.6.33",
|
|
});
|
|
installPluginFromClawHubMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "artifact_unavailable",
|
|
error: "artifact unavailable",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValueOnce(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "discord",
|
|
targetDir: "/tmp/openclaw-plugins/discord",
|
|
version: "2026.7.33",
|
|
npmResolution: {
|
|
name: "@openclaw/discord",
|
|
version: "2026.7.33",
|
|
resolvedSpec: "@openclaw/discord@2026.7.33",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await updatePlugin(
|
|
createClawHubInstallConfig({
|
|
pluginId: "discord",
|
|
installPath,
|
|
clawhubPackage: "@openclaw/discord",
|
|
}),
|
|
"discord",
|
|
{
|
|
syncOfficialPluginInstalls: true,
|
|
officialPluginUpdateChannel: "extended-stable",
|
|
coreVersion: "2026.7.33",
|
|
},
|
|
);
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/discord@2026.7.33");
|
|
expectRecordFields(result.config.plugins?.installs?.discord, {
|
|
source: "npm",
|
|
spec: "@openclaw/discord",
|
|
version: "2026.7.33",
|
|
resolvedSpec: "@openclaw/discord@2026.7.33",
|
|
});
|
|
});
|
|
|
|
it("reports npm dry-run versions for trusted official ClawHub artifact fallback", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/discord",
|
|
version: "2026.5.16-beta.5",
|
|
});
|
|
installPluginFromClawHubMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "artifact_unavailable",
|
|
error: "artifact unavailable",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
pluginId: "discord",
|
|
targetDir: "/tmp/openclaw-plugins/discord",
|
|
extensions: [],
|
|
npmResolution: {
|
|
name: "@openclaw/discord",
|
|
version: "2026.5.16-beta.5",
|
|
resolvedSpec: "@openclaw/discord@2026.5.16-beta.5",
|
|
},
|
|
});
|
|
|
|
const result = await updatePlugin(
|
|
createClawHubInstallConfig({
|
|
pluginId: "discord",
|
|
installPath,
|
|
clawhubPackage: "@openclaw/discord",
|
|
}),
|
|
"discord",
|
|
{
|
|
updateChannel: "beta",
|
|
dryRun: true,
|
|
},
|
|
);
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/discord@beta");
|
|
expect(npmInstallCall()?.dryRun).toBe(true);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "discord",
|
|
status: "unchanged",
|
|
currentVersion: "2026.5.16-beta.5",
|
|
nextVersion: "2026.5.16-beta.5",
|
|
message:
|
|
"discord is up to date (2026.5.16-beta.5). (warning: official ClawHub artifact fallback would use @openclaw/discord@beta).",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not fall back to trusted npm from custom ClawHub provenance", async () => {
|
|
const installPath = createInstalledPackageDir({
|
|
name: "@openclaw/discord",
|
|
version: "2026.5.12",
|
|
});
|
|
installPluginFromClawHubMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
code: "artifact_unavailable",
|
|
error: "artifact unavailable",
|
|
});
|
|
|
|
const result = await updatePlugin(
|
|
createClawHubInstallConfig({
|
|
pluginId: "discord",
|
|
installPath,
|
|
clawhubUrl: "https://custom-clawhub.example",
|
|
clawhubPackage: "@openclaw/discord",
|
|
}),
|
|
"discord",
|
|
{ updateChannel: "beta" },
|
|
);
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "discord",
|
|
status: "error",
|
|
message:
|
|
"Failed to update discord: artifact unavailable (ClawHub clawhub:@openclaw/discord@beta).",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("preserves explicit ClawHub tags when updating on the beta channel", async () => {
|
|
installPluginFromClawHubMock.mockResolvedValue(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId: "demo",
|
|
targetDir: "/tmp/demo",
|
|
version: "1.3.0-rc.1",
|
|
clawhubPackage: "demo",
|
|
}),
|
|
);
|
|
|
|
await updatePlugin(createClawHubInstallConfig({ spec: "clawhub:demo@rc" }), "demo", {
|
|
updateChannel: "beta",
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:demo@rc");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "skips ClawHub plugin update when bundled version is newer",
|
|
pluginId: "whatsapp",
|
|
bundledVersion: "2026.4.20",
|
|
installedVersion: "2026.2.9",
|
|
clawhubFamily: "bundle-plugin",
|
|
clawhubChannel: "community",
|
|
nextVersion: undefined,
|
|
},
|
|
{
|
|
name: "proceeds with ClawHub plugin update when bundled version is older",
|
|
pluginId: "demo",
|
|
bundledVersion: "1.0.0",
|
|
installedVersion: "1.5.0",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
nextVersion: "2.0.0",
|
|
},
|
|
{
|
|
name: "does not treat an older bundled stable release as newer than an installed correction release",
|
|
pluginId: "demo",
|
|
bundledVersion: "2026.5.3",
|
|
installedVersion: "2026.5.3-1",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
nextVersion: "2026.5.3-2",
|
|
},
|
|
] as const)(
|
|
"$name",
|
|
async ({
|
|
pluginId,
|
|
bundledVersion,
|
|
installedVersion,
|
|
clawhubFamily,
|
|
clawhubChannel,
|
|
nextVersion,
|
|
}) => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(
|
|
new Map([
|
|
[
|
|
pluginId,
|
|
{ pluginId, localPath: appBundledPluginRoot(pluginId), version: bundledVersion },
|
|
],
|
|
]),
|
|
);
|
|
if (nextVersion) {
|
|
installPluginFromClawHubMock.mockResolvedValue(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId,
|
|
targetDir: `/tmp/${pluginId}`,
|
|
version: nextVersion,
|
|
clawhubPackage: pluginId,
|
|
}),
|
|
);
|
|
}
|
|
const config = createClawHubInstallConfig({ pluginId, clawhubFamily, clawhubChannel });
|
|
const install = config.plugins?.installs?.[pluginId];
|
|
if (!install) {
|
|
throw new Error(`Missing ClawHub install fixture for ${pluginId}`);
|
|
}
|
|
install.version = installedVersion;
|
|
const warnings: string[] = [];
|
|
const result = await updatePlugin(config, pluginId, {
|
|
logger: { warn: (message) => warnings.push(message) },
|
|
});
|
|
|
|
if (!nextVersion) {
|
|
expect(installPluginFromClawHubMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.outcomes).toHaveLength(1);
|
|
expectRecordFields(result.outcomes[0], { pluginId, status: "skipped" });
|
|
expect(result.outcomes[0]?.message).toContain(`bundled version ${bundledVersion} is newer`);
|
|
expect(warnings).toHaveLength(1);
|
|
expect(warnings[0]).toContain(`bundled version ${bundledVersion} is newer`);
|
|
return;
|
|
}
|
|
|
|
expect(installPluginFromClawHubMock).toHaveBeenCalled();
|
|
expect(result.changed).toBe(true);
|
|
if (installedVersion.includes("-")) {
|
|
expectRecordFields(result.outcomes[0], {
|
|
pluginId,
|
|
status: "updated",
|
|
currentVersion: undefined,
|
|
nextVersion,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it("forwards ClawHub risk acknowledgement inputs without dry-run prompts", async () => {
|
|
const onClawHubRisk = vi.fn(async () => true);
|
|
const config = createClawHubInstallConfig();
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: "/tmp/demo",
|
|
version: "1.2.4",
|
|
clawhub: {
|
|
source: "clawhub",
|
|
clawhubUrl: "https://clawhub.ai",
|
|
clawhubPackage: "demo",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
integrity: "sha256-next",
|
|
resolvedAt: "2026-03-22T00:00:00.000Z",
|
|
},
|
|
});
|
|
|
|
for (const dryRun of [true, false]) {
|
|
installPluginFromClawHubMock.mockClear();
|
|
|
|
await updatePlugin(config, "demo", {
|
|
acknowledgeClawHubRisk: true,
|
|
onClawHubRisk,
|
|
...(dryRun ? { dryRun: true } : {}),
|
|
});
|
|
|
|
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spec: "clawhub:demo",
|
|
acknowledgeClawHubRisk: true,
|
|
...(dryRun ? { dryRun: true } : {}),
|
|
...(!dryRun ? { onClawHubRisk } : {}),
|
|
}),
|
|
);
|
|
if (dryRun) {
|
|
expect(clawHubInstallCall()?.onClawHubRisk).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "@openclaw/voice-call",
|
|
targetDir: "/tmp/openclaw-voice-call",
|
|
version: "0.0.2",
|
|
extensions: ["index.ts"],
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
allow: ["voice-call"],
|
|
deny: ["voice-call"],
|
|
slots: { memory: "voice-call" },
|
|
entries: {
|
|
"voice-call": {
|
|
enabled: false,
|
|
hooks: { allowPromptInjection: false },
|
|
},
|
|
},
|
|
installs: {
|
|
"voice-call": {
|
|
source: "npm",
|
|
spec: "@openclaw/voice-call",
|
|
installPath: "/tmp/voice-call",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["voice-call"],
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/voice-call");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("voice-call");
|
|
expect(result.config.plugins?.allow).toEqual(["@openclaw/voice-call"]);
|
|
expect(result.config.plugins?.deny).toEqual(["@openclaw/voice-call"]);
|
|
expect(result.config.plugins?.slots?.memory).toBe("@openclaw/voice-call");
|
|
expect(result.config.plugins?.entries?.["@openclaw/voice-call"]).toEqual({
|
|
enabled: false,
|
|
hooks: { allowPromptInjection: false },
|
|
});
|
|
expect(result.config.plugins?.entries?.["voice-call"]).toBeUndefined();
|
|
expectRecordFields(result.config.plugins?.installs?.["@openclaw/voice-call"], {
|
|
source: "npm",
|
|
spec: "@openclaw/voice-call",
|
|
installPath: "/tmp/openclaw-voice-call",
|
|
version: "0.0.2",
|
|
});
|
|
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
|
|
});
|
|
|
|
it("keeps authored plugin config shape when only the install key migrates", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "@openclaw/voice-call",
|
|
targetDir: "/tmp/openclaw-voice-call",
|
|
version: "0.0.2",
|
|
extensions: ["index.ts"],
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
installs: {
|
|
"voice-call": {
|
|
source: "npm",
|
|
spec: "@openclaw/voice-call",
|
|
installPath: "/tmp/voice-call",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["voice-call"],
|
|
});
|
|
|
|
expect(result.config.plugins).toEqual({
|
|
installs: {
|
|
"@openclaw/voice-call": expect.objectContaining({
|
|
source: "npm",
|
|
spec: "@openclaw/voice-call",
|
|
installPath: "/tmp/openclaw-voice-call",
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("migrates context engine slot when a plugin id changes during update", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "@openclaw/context-engine",
|
|
targetDir: "/tmp/openclaw-context-engine",
|
|
version: "0.0.2",
|
|
extensions: ["index.ts"],
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
slots: { contextEngine: "context-engine" },
|
|
installs: {
|
|
"context-engine": {
|
|
source: "npm",
|
|
spec: "@openclaw/context-engine",
|
|
installPath: "/tmp/context-engine",
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
pluginIds: ["context-engine"],
|
|
});
|
|
|
|
expect(result.config.plugins?.slots?.contextEngine).toBe("@openclaw/context-engine");
|
|
expectRecordFields(result.config.plugins?.installs?.["@openclaw/context-engine"], {
|
|
source: "npm",
|
|
spec: "@openclaw/context-engine",
|
|
installPath: "/tmp/openclaw-context-engine",
|
|
version: "0.0.2",
|
|
});
|
|
expect(result.config.plugins?.installs?.["context-engine"]).toBeUndefined();
|
|
});
|
|
|
|
it("checks marketplace installs during dry-run updates", async () => {
|
|
installPluginFromMarketplaceMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "claude-bundle",
|
|
targetDir: "/tmp/claude-bundle",
|
|
version: "1.2.0",
|
|
extensions: ["index.ts"],
|
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
|
marketplacePlugin: "claude-bundle",
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createMarketplaceInstallConfig({
|
|
pluginId: "claude-bundle",
|
|
installPath: "/tmp/claude-bundle",
|
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
|
marketplacePlugin: "claude-bundle",
|
|
}),
|
|
pluginIds: ["claude-bundle"],
|
|
timeoutMs: 1_800_000,
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(marketplaceInstallCall()?.marketplace).toBe("vincentkoc/claude-marketplace");
|
|
expect(marketplaceInstallCall()?.plugin).toBe("claude-bundle");
|
|
expect(marketplaceInstallCall()?.expectedPluginId).toBe("claude-bundle");
|
|
expect(marketplaceInstallCall()?.dryRun).toBe(true);
|
|
expect(marketplaceInstallCall()?.timeoutMs).toBe(1_800_000);
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "claude-bundle",
|
|
status: "updated",
|
|
currentVersion: undefined,
|
|
nextVersion: "1.2.0",
|
|
message: "Would update claude-bundle: unknown -> 1.2.0.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("updates marketplace installs and preserves source metadata", async () => {
|
|
installPluginFromMarketplaceMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "claude-bundle",
|
|
targetDir: "/tmp/claude-bundle",
|
|
version: "1.3.0",
|
|
extensions: ["index.ts"],
|
|
marketplaceName: "Vincent's Claude Plugins",
|
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
|
marketplacePlugin: "claude-bundle",
|
|
});
|
|
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: createMarketplaceInstallConfig({
|
|
pluginId: "claude-bundle",
|
|
installPath: "/tmp/claude-bundle",
|
|
marketplaceName: "Vincent's Claude Plugins",
|
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
|
marketplacePlugin: "claude-bundle",
|
|
}),
|
|
pluginIds: ["claude-bundle"],
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expectRecordFields(result.config.plugins?.installs?.["claude-bundle"], {
|
|
source: "marketplace",
|
|
installPath: "/tmp/claude-bundle",
|
|
version: "1.3.0",
|
|
marketplaceName: "Vincent's Claude Plugins",
|
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
|
marketplacePlugin: "claude-bundle",
|
|
});
|
|
});
|
|
|
|
it("updates git installs and records resolved commit metadata", async () => {
|
|
installPluginFromGitSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: "/tmp/demo",
|
|
version: "1.3.0",
|
|
extensions: ["index.ts"],
|
|
git: {
|
|
url: "https://github.com/acme/demo.git",
|
|
ref: "main",
|
|
commit: "def456",
|
|
resolvedAt: "2026-04-30T00:00:00.000Z",
|
|
},
|
|
});
|
|
|
|
const result = await updatePlugin(
|
|
createGitInstallConfig({
|
|
pluginId: "demo",
|
|
installPath: "/tmp/demo",
|
|
spec: "git:github.com/acme/demo@main",
|
|
commit: "abc123",
|
|
}),
|
|
"demo",
|
|
);
|
|
|
|
expect(gitInstallCall()?.spec).toBe("git:github.com/acme/demo@main");
|
|
expect(gitInstallCall()?.expectedPluginId).toBe("demo");
|
|
expect(gitInstallCall()?.mode).toBe("update");
|
|
expect(result.changed).toBe(true);
|
|
expectRecordFields(result.config.plugins?.installs?.demo, {
|
|
source: "git",
|
|
spec: "git:github.com/acme/demo@main",
|
|
installPath: "/tmp/demo",
|
|
version: "1.3.0",
|
|
gitUrl: "https://github.com/acme/demo.git",
|
|
gitRef: "main",
|
|
gitCommit: "def456",
|
|
});
|
|
});
|
|
|
|
it("forwards dangerous force unsafe install to plugin update installers", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "openclaw-codex-app-server",
|
|
targetDir: "/tmp/openclaw-codex-app-server",
|
|
version: "0.2.0-beta.4",
|
|
}),
|
|
);
|
|
|
|
await updatePlugin(
|
|
createCodexAppServerInstallConfig({
|
|
spec: "openclaw-codex-app-server@beta",
|
|
}),
|
|
"openclaw-codex-app-server",
|
|
{ dangerouslyForceUnsafeInstall: true },
|
|
);
|
|
|
|
expect(npmInstallCall()?.spec).toBe("openclaw-codex-app-server@beta");
|
|
expect(npmInstallCall()?.dangerouslyForceUnsafeInstall).toBe(true);
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("openclaw-codex-app-server");
|
|
});
|
|
|
|
it("reuses the recorded managed extensions root when updating external plugins", async () => {
|
|
const installPath = "/var/openclaw/extensions/demo";
|
|
const extensionsDir = "/var/openclaw/extensions";
|
|
const expectedExtensionsDir = path.resolve(extensionsDir);
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "demo",
|
|
targetDir: installPath,
|
|
version: "1.2.0",
|
|
}),
|
|
);
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: installPath,
|
|
version: "1.2.0",
|
|
extensions: ["index.ts"],
|
|
clawhub: {
|
|
source: "clawhub",
|
|
clawhubUrl: "https://clawhub.ai",
|
|
clawhubPackage: "demo",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
integrity: "sha256-next",
|
|
resolvedAt: "2026-03-22T00:00:00.000Z",
|
|
},
|
|
});
|
|
installPluginFromMarketplaceMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: installPath,
|
|
version: "1.2.0",
|
|
extensions: ["index.ts"],
|
|
marketplaceSource: "acme/plugins",
|
|
marketplacePlugin: "demo",
|
|
});
|
|
installPluginFromGitSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: installPath,
|
|
version: "1.2.0",
|
|
extensions: ["index.ts"],
|
|
git: {
|
|
url: "https://github.com/acme/demo.git",
|
|
ref: "main",
|
|
commit: "abc123",
|
|
resolvedAt: "2026-04-30T00:00:00.000Z",
|
|
},
|
|
});
|
|
|
|
await updatePlugin(
|
|
createNpmInstallConfig({
|
|
pluginId: "demo",
|
|
spec: "@acme/demo",
|
|
installPath,
|
|
}),
|
|
"demo",
|
|
);
|
|
await updatePlugin(createClawHubInstallConfig({ installPath }), "demo");
|
|
await updatePlugin(
|
|
createMarketplaceInstallConfig({
|
|
pluginId: "demo",
|
|
installPath,
|
|
marketplaceSource: "acme/plugins",
|
|
marketplacePlugin: "demo",
|
|
}),
|
|
"demo",
|
|
);
|
|
await updatePlugin(
|
|
createGitInstallConfig({
|
|
pluginId: "demo",
|
|
installPath,
|
|
spec: "git:github.com/acme/demo@main",
|
|
}),
|
|
"demo",
|
|
);
|
|
|
|
expect(npmInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
|
expect(clawHubInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
|
expect(marketplaceInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
|
expect(gitInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
|
});
|
|
});
|
|
|
|
describe("syncPluginsForUpdateChannel", () => {
|
|
beforeEach(() => {
|
|
installPluginFromNpmSpecMock.mockReset();
|
|
installPluginFromClawHubMock.mockReset();
|
|
installPluginFromGitSpecMock.mockReset();
|
|
resolveBundledPluginSourcesMock.mockReset();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "keeps bundled path installs on beta without reinstalling from npm",
|
|
config: createBundledPathInstallConfig({
|
|
loadPaths: [appBundledPluginRoot("feishu")],
|
|
installPath: appBundledPluginRoot("feishu"),
|
|
spec: "@openclaw/feishu",
|
|
}),
|
|
expectedChanged: false,
|
|
expectedLoadPaths: [appBundledPluginRoot("feishu")],
|
|
expectedInstallPath: appBundledPluginRoot("feishu"),
|
|
},
|
|
{
|
|
name: "repairs bundled install metadata when the load path is re-added",
|
|
config: createBundledPathInstallConfig({
|
|
loadPaths: [],
|
|
installPath: "/tmp/old-feishu",
|
|
spec: "@openclaw/feishu",
|
|
}),
|
|
expectedChanged: true,
|
|
expectedLoadPaths: [appBundledPluginRoot("feishu")],
|
|
expectedInstallPath: appBundledPluginRoot("feishu"),
|
|
},
|
|
] as const)(
|
|
"$name",
|
|
async ({ config, expectedChanged, expectedLoadPaths, expectedInstallPath }) => {
|
|
mockBundledSources(createBundledSource());
|
|
|
|
const result = await syncPluginsForUpdateChannel({
|
|
channel: "beta",
|
|
config,
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(expectedChanged);
|
|
expect(result.summary.switchedToNpm).toStrictEqual([]);
|
|
expect(result.config.plugins?.load?.paths).toEqual(expectedLoadPaths);
|
|
expectBundledPathInstall({
|
|
install: result.config.plugins?.installs?.feishu,
|
|
sourcePath: appBundledPluginRoot("feishu"),
|
|
installPath: expectedInstallPath,
|
|
spec: "@openclaw/feishu",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("forwards an explicit env to bundled plugin source resolution", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
|
|
|
await syncPluginsForUpdateChannel({
|
|
channel: "beta",
|
|
config: {},
|
|
workspaceDir: "/workspace",
|
|
env,
|
|
});
|
|
|
|
expect(resolveBundledPluginSourcesMock).toHaveBeenCalledWith({
|
|
workspaceDir: "/workspace",
|
|
env,
|
|
});
|
|
});
|
|
|
|
it("uses the provided env when matching bundled load and install paths", async () => {
|
|
const bundledHome = "/tmp/openclaw-home";
|
|
mockBundledSources(
|
|
createBundledSource({
|
|
localPath: `${bundledHome}/plugins/feishu`,
|
|
}),
|
|
);
|
|
|
|
await withEnvAsync({ HOME: "/tmp/process-home" }, async () => {
|
|
const result = await syncPluginsForUpdateChannel({
|
|
channel: "beta",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_HOME: bundledHome,
|
|
HOME: "/tmp/ignored-home",
|
|
},
|
|
config: {
|
|
plugins: {
|
|
load: { paths: ["~/plugins/feishu"] },
|
|
installs: {
|
|
feishu: {
|
|
source: "path",
|
|
sourcePath: "~/plugins/feishu",
|
|
installPath: "~/plugins/feishu",
|
|
spec: "@openclaw/feishu",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config.plugins?.load?.paths).toEqual(["~/plugins/feishu"]);
|
|
expectBundledPathInstall({
|
|
install: result.config.plugins?.installs?.feishu,
|
|
sourcePath: "~/plugins/feishu",
|
|
installPath: "~/plugins/feishu",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("installs an externalized bundled plugin and rewrites its old bundled path plugin index", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "legacy-chat",
|
|
targetDir: "/tmp/openclaw-plugins/legacy-chat",
|
|
version: "2.0.0",
|
|
npmResolution: {
|
|
name: "@openclaw/legacy-chat",
|
|
version: "2.0.0",
|
|
resolvedSpec: "@openclaw/legacy-chat@2.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await syncExternalizedPlugin({});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/legacy-chat");
|
|
expect(npmInstallCall()?.mode).toBe("update");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("legacy-chat");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).not.toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.summary.switchedToNpm).toEqual(["legacy-chat"]);
|
|
expect(result.summary.errors).toStrictEqual([]);
|
|
expect(result.config.plugins?.load?.paths).toStrictEqual([]);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], {
|
|
source: "npm",
|
|
spec: "@openclaw/legacy-chat",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
version: "2.0.0",
|
|
resolvedName: "@openclaw/legacy-chat",
|
|
resolvedVersion: "2.0.0",
|
|
resolvedSpec: "@openclaw/legacy-chat@2.0.0",
|
|
});
|
|
});
|
|
|
|
it("marks official externalized bundled npm installs as trusted", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "voice-call",
|
|
targetDir: "/tmp/openclaw-plugins/voice-call",
|
|
version: "0.0.2-beta.1",
|
|
}),
|
|
);
|
|
|
|
await syncExternalizedPlugin({
|
|
bridge: {
|
|
bundledPluginId: "voice-call",
|
|
npmSpec: "@openclaw/voice-call",
|
|
channelIds: ["voice-call"],
|
|
},
|
|
config: createExternalizedPluginConfig({ pluginId: "voice-call" }),
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/voice-call");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("voice-call");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
});
|
|
|
|
it("installs a ClawHub-preferred externalized bundled plugin", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromClawHubMock.mockResolvedValue(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId: "legacy-chat",
|
|
targetDir: "/tmp/openclaw-plugins/legacy-chat",
|
|
version: "2026.5.1-beta.2",
|
|
clawhubPackage: "legacy-chat",
|
|
}),
|
|
);
|
|
const onClawHubRisk = vi.fn(async () => true);
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
acknowledgeClawHubRisk: true,
|
|
onClawHubRisk,
|
|
bridge: {
|
|
preferredSource: "clawhub",
|
|
clawhubSpec: "clawhub:legacy-chat@2026.5.1-beta.2",
|
|
clawhubUrl: "https://clawhub.ai",
|
|
},
|
|
});
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:legacy-chat@2026.5.1-beta.2");
|
|
expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai");
|
|
expect(clawHubInstallCall()?.mode).toBe("update");
|
|
expect(clawHubInstallCall()?.expectedPluginId).toBe("legacy-chat");
|
|
expect(clawHubInstallCall()?.acknowledgeClawHubRisk).toBe(true);
|
|
expect(clawHubInstallCall()?.onClawHubRisk).toBe(onClawHubRisk);
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(true);
|
|
expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]);
|
|
expect(result.summary.switchedToNpm).toStrictEqual([]);
|
|
expect(result.summary.errors).toStrictEqual([]);
|
|
expect(result.config.plugins?.load?.paths).toStrictEqual([]);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], {
|
|
source: "clawhub",
|
|
spec: "clawhub:legacy-chat@2026.5.1-beta.2",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
version: "2026.5.1-beta.2",
|
|
integrity: "sha256-clawpack",
|
|
clawhubUrl: "https://clawhub.ai",
|
|
clawhubPackage: "legacy-chat",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
artifactKind: "npm-pack",
|
|
artifactFormat: "tgz",
|
|
npmIntegrity: "sha512-clawpack",
|
|
npmShasum: "2".repeat(40),
|
|
npmTarballName: "legacy-chat-2026.5.1-beta.2.tgz",
|
|
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
clawpackSpecVersion: 1,
|
|
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
clawpackSize: 4096,
|
|
});
|
|
});
|
|
|
|
it("falls back from ClawHub to npm only when the ClawHub package is absent", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "package_not_found",
|
|
error: "Package not found on ClawHub.",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "legacy-chat",
|
|
targetDir: "/tmp/openclaw-plugins/legacy-chat",
|
|
version: "2.0.0",
|
|
}),
|
|
);
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
bridge: {
|
|
preferredSource: "clawhub",
|
|
clawhubSpec: "clawhub:legacy-chat@2026.5.1-beta.2",
|
|
},
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/legacy-chat");
|
|
expect(npmInstallCall()?.mode).toBe("update");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("legacy-chat");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).not.toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.summary.switchedToClawHub).toStrictEqual([]);
|
|
expect(result.summary.switchedToNpm).toEqual(["legacy-chat"]);
|
|
expect(result.summary.warnings).toEqual([
|
|
"ClawHub clawhub:legacy-chat@2026.5.1-beta.2 unavailable for legacy-chat; falling back to npm @openclaw/legacy-chat.",
|
|
]);
|
|
expect(result.summary.errors).toStrictEqual([]);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], {
|
|
source: "npm",
|
|
spec: "@openclaw/legacy-chat",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
version: "2.0.0",
|
|
});
|
|
});
|
|
|
|
it("uses exact-core npm when an official ClawHub bridge falls back on extended-stable", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "package_not_found",
|
|
error: "Package not found on ClawHub.",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "voice-call",
|
|
targetDir: "/tmp/openclaw-plugins/voice-call",
|
|
version: "2026.7.33",
|
|
npmResolution: {
|
|
name: "@openclaw/voice-call",
|
|
version: "2026.7.33",
|
|
resolvedSpec: "@openclaw/voice-call@2026.7.33",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
channel: "extended-stable",
|
|
coreVersion: "2026.7.33",
|
|
bridge: {
|
|
bundledPluginId: "voice-call",
|
|
preferredSource: "clawhub",
|
|
clawhubSpec: "clawhub:@openclaw/voice-call",
|
|
npmSpec: "@openclaw/voice-call",
|
|
channelIds: ["voice-call"],
|
|
},
|
|
config: createExternalizedPluginConfig({ pluginId: "voice-call", includeLoad: false }),
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/voice-call@2026.7.33");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
expectRecordFields(result.config.plugins?.installs?.["voice-call"], {
|
|
source: "npm",
|
|
spec: "@openclaw/voice-call",
|
|
version: "2026.7.33",
|
|
resolvedSpec: "@openclaw/voice-call@2026.7.33",
|
|
});
|
|
});
|
|
|
|
it("does not fall back from ClawHub to non-OpenClaw npm packages", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "package_not_found",
|
|
error: "Package not found on ClawHub.",
|
|
});
|
|
const config = createExternalizedPluginConfig();
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
bridge: {
|
|
preferredSource: "clawhub",
|
|
clawhubSpec: "clawhub:legacy-chat@2026.5.1-beta.2",
|
|
npmSpec: "@someone-else/legacy-chat",
|
|
},
|
|
config,
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.summary.switchedToNpm).toStrictEqual([]);
|
|
expect(result.summary.warnings).toStrictEqual([]);
|
|
expect(result.summary.errors).toEqual([
|
|
"Failed to update legacy-chat: Package not found on ClawHub. (ClawHub clawhub:legacy-chat@2026.5.1-beta.2).",
|
|
]);
|
|
});
|
|
|
|
it("falls back from official ClawHub artifact misses to trusted npm packages", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "artifact_download_unavailable",
|
|
error: "ClawHub ClawPack artifact is unavailable.",
|
|
});
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "voice-call",
|
|
targetDir: "/tmp/openclaw-plugins/voice-call",
|
|
version: "0.0.2-beta.1",
|
|
}),
|
|
);
|
|
|
|
await syncExternalizedPlugin({
|
|
bridge: {
|
|
bundledPluginId: "voice-call",
|
|
preferredSource: "clawhub",
|
|
clawhubSpec: "clawhub:@openclaw/voice-call",
|
|
npmSpec: "@openclaw/voice-call",
|
|
channelIds: ["voice-call"],
|
|
},
|
|
config: createExternalizedPluginConfig({ pluginId: "voice-call" }),
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/voice-call");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("voice-call");
|
|
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
|
|
});
|
|
|
|
it("moves ClawHub-preferred externalized plugin fallbacks back to ClawHub", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromClawHubMock.mockResolvedValue(
|
|
createSuccessfulClawHubUpdateResult({
|
|
pluginId: "legacy-chat",
|
|
targetDir: "/tmp/openclaw-plugins/legacy-chat",
|
|
version: "2026.5.1-beta.2",
|
|
clawhubPackage: "legacy-chat",
|
|
}),
|
|
);
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
bridge: {
|
|
preferredSource: "clawhub",
|
|
clawhubSpec: "clawhub:legacy-chat@2026.5.1-beta.2",
|
|
},
|
|
config: createExternalizedPluginConfig({
|
|
includeLoad: false,
|
|
install: {
|
|
source: "npm",
|
|
spec: "@openclaw/legacy-chat",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(clawHubInstallCall()?.spec).toBe("clawhub:legacy-chat@2026.5.1-beta.2");
|
|
expect(clawHubInstallCall()?.mode).toBe("update");
|
|
expect(clawHubInstallCall()?.expectedPluginId).toBe("legacy-chat");
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(true);
|
|
expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], {
|
|
source: "clawhub",
|
|
spec: "clawhub:legacy-chat@2026.5.1-beta.2",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
});
|
|
});
|
|
|
|
it("fails closed without npm fallback when ClawHub returns integrity drift", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromClawHubMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "archive_integrity_mismatch",
|
|
error: "ClawHub ClawPack integrity mismatch.",
|
|
warning: "WARNING\nSecurity scan: suspicious",
|
|
});
|
|
const config = createExternalizedPluginConfig();
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
bridge: {
|
|
preferredSource: "clawhub",
|
|
clawhubSpec: "clawhub:legacy-chat@2026.5.1-beta.2",
|
|
},
|
|
config,
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.summary.warnings).toEqual(["WARNING\nSecurity scan: suspicious"]);
|
|
expect(result.summary.errors).toEqual([
|
|
"Failed to update legacy-chat: ClawHub ClawPack integrity mismatch. (ClawHub clawhub:legacy-chat@2026.5.1-beta.2).",
|
|
]);
|
|
});
|
|
|
|
it("externalizes bundled plugins that were enabled by default", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromNpmSpecMock.mockResolvedValue(
|
|
createSuccessfulNpmUpdateResult({
|
|
pluginId: "default-chat",
|
|
targetDir: "/tmp/openclaw-plugins/default-chat",
|
|
version: "2.0.0",
|
|
}),
|
|
);
|
|
|
|
const result = await syncPluginsForUpdateChannel({
|
|
channel: "stable",
|
|
externalizedBundledPluginBridges: [
|
|
{
|
|
bundledPluginId: "default-chat",
|
|
enabledByDefault: true,
|
|
npmSpec: "@openclaw/default-chat",
|
|
channelIds: ["default-chat"],
|
|
},
|
|
],
|
|
config: {},
|
|
});
|
|
|
|
expect(npmInstallCall()?.spec).toBe("@openclaw/default-chat");
|
|
expect(npmInstallCall()?.mode).toBe("update");
|
|
expect(npmInstallCall()?.expectedPluginId).toBe("default-chat");
|
|
expect(result.changed).toBe(true);
|
|
expect(result.summary.switchedToNpm).toEqual(["default-chat"]);
|
|
expectRecordFields(result.config.plugins?.installs?.["default-chat"], {
|
|
source: "npm",
|
|
spec: "@openclaw/default-chat",
|
|
installPath: "/tmp/openclaw-plugins/default-chat",
|
|
version: "2.0.0",
|
|
});
|
|
});
|
|
|
|
it("does not externalize disabled bundled plugins", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
config: createExternalizedPluginConfig({
|
|
channelEnabled: false,
|
|
entryEnabled: false,
|
|
}),
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], {
|
|
source: "path",
|
|
});
|
|
});
|
|
|
|
it("leaves config unchanged when externalized plugin installation fails", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "package unavailable",
|
|
});
|
|
const config = createExternalizedPluginConfig();
|
|
|
|
const result = await syncExternalizedPlugin({ config });
|
|
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toBe(config);
|
|
expect(result.summary.errors).toEqual(["Failed to update legacy-chat: package unavailable"]);
|
|
});
|
|
|
|
it("does not externalize custom local path installs that only share the old plugin id", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
config: createExternalizedPluginConfig({
|
|
loadPaths: ["/workspace/plugins/legacy-chat"],
|
|
install: {
|
|
source: "path",
|
|
sourcePath: "/workspace/plugins/legacy-chat",
|
|
installPath: "/workspace/plugins/legacy-chat",
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], {
|
|
source: "path",
|
|
sourcePath: "/workspace/plugins/legacy-chat",
|
|
});
|
|
});
|
|
|
|
it("does not externalize while the bundled source is still present in the current build", async () => {
|
|
mockBundledSources(
|
|
createBundledSource({
|
|
pluginId: "legacy-chat",
|
|
localPath: appBundledPluginRoot("legacy-chat"),
|
|
}),
|
|
);
|
|
|
|
const result = await syncExternalizedPlugin({});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], {
|
|
source: "path",
|
|
});
|
|
});
|
|
|
|
it.each(["constructor", "__proto__"])(
|
|
"migrates already-externalized records to prototype-named plugin id %s",
|
|
async (targetPluginId) => {
|
|
const legacyPluginId = `legacy-${targetPluginId}`;
|
|
const npmPackageName = `openclaw-plugin-${targetPluginId}`;
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
|
|
const result = await syncPluginsForUpdateChannel({
|
|
channel: "stable",
|
|
externalizedBundledPluginBridges: [
|
|
{
|
|
bundledPluginId: legacyPluginId,
|
|
pluginId: targetPluginId,
|
|
npmSpec: npmPackageName,
|
|
channelIds: [],
|
|
},
|
|
],
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
[legacyPluginId]: { enabled: true },
|
|
},
|
|
installs: {
|
|
[legacyPluginId]: {
|
|
source: "npm",
|
|
spec: npmPackageName,
|
|
installPath: `/tmp/${targetPluginId}`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(true);
|
|
expect(Object.hasOwn(result.config.plugins?.entries ?? {}, targetPluginId)).toBe(true);
|
|
expect(Object.getPrototypeOf(result.config.plugins?.entries ?? {})).toBe(Object.prototype);
|
|
expect(result.config.plugins?.entries?.[targetPluginId]).toEqual({ enabled: true });
|
|
expect(Object.hasOwn(result.config.plugins?.installs ?? {}, targetPluginId)).toBe(true);
|
|
expect(Object.getPrototypeOf(result.config.plugins?.installs ?? {})).toBe(Object.prototype);
|
|
expectRecordFields(result.config.plugins?.installs?.[targetPluginId], {
|
|
source: "npm",
|
|
spec: npmPackageName,
|
|
installPath: `/tmp/${targetPluginId}`,
|
|
});
|
|
expect(result.config.plugins?.entries?.[legacyPluginId]).toBeUndefined();
|
|
expect(result.config.plugins?.installs?.[legacyPluginId]).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
name: "removes stale bundled load paths for already-externalized npm installs",
|
|
install: {
|
|
source: "npm",
|
|
spec: "@openclaw/legacy-chat",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
},
|
|
expectedInstall: { source: "npm", spec: "@openclaw/legacy-chat" },
|
|
bridge: {},
|
|
expectClawHubNotCalled: false,
|
|
},
|
|
{
|
|
name: "removes stale bundled load paths for already-externalized resolved-name-only npm installs",
|
|
install: {
|
|
source: "npm",
|
|
resolvedName: "@openclaw/legacy-chat",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
},
|
|
expectedInstall: { source: "npm", resolvedName: "@openclaw/legacy-chat" },
|
|
bridge: {},
|
|
expectClawHubNotCalled: false,
|
|
},
|
|
{
|
|
name: "removes stale bundled load paths for already-externalized pinned npm installs",
|
|
install: {
|
|
source: "npm",
|
|
spec: "@openclaw/legacy-chat@1.2.3",
|
|
resolvedSpec: "@openclaw/legacy-chat@1.2.3",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
},
|
|
expectedInstall: { source: "npm", spec: "@openclaw/legacy-chat@1.2.3" },
|
|
bridge: {},
|
|
expectClawHubNotCalled: false,
|
|
},
|
|
{
|
|
name: "removes stale bundled load paths for already-externalized pinned ClawHub installs",
|
|
install: {
|
|
source: "clawhub",
|
|
spec: "clawhub:legacy-chat@2026.5.1",
|
|
clawhubPackage: "legacy-chat",
|
|
installPath: "/tmp/openclaw-plugins/legacy-chat",
|
|
},
|
|
expectedInstall: { source: "clawhub", spec: "clawhub:legacy-chat@2026.5.1" },
|
|
bridge: { preferredSource: "clawhub", clawhubSpec: "clawhub:legacy-chat" },
|
|
expectClawHubNotCalled: true,
|
|
},
|
|
] as const)("$name", async ({ install, expectedInstall, bridge, expectClawHubNotCalled }) => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
|
|
const result = await syncExternalizedPlugin({
|
|
bridge,
|
|
config: createExternalizedPluginConfig({
|
|
loadPaths: [appBundledPluginRoot("legacy-chat"), "/workspace/plugins/other"],
|
|
install,
|
|
}),
|
|
});
|
|
|
|
if (expectClawHubNotCalled) {
|
|
expect(installPluginFromClawHubMock).not.toHaveBeenCalled();
|
|
}
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.load?.paths).toEqual(["/workspace/plugins/other"]);
|
|
expectRecordFields(result.config.plugins?.installs?.["legacy-chat"], expectedInstall);
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|