mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-29 23:03:35 +00:00
fix(cli): harden official plugin recovery (#93325)
* fix(cli): harden official plugin recovery * fix(config): preserve include write context * fix(config): reject external include mutations * fix(config): bind snapshots to config paths * fix(config): preserve write ownership * fix(cli): preflight plugin config mutations * chore(plugin-sdk): refresh api baseline * test(config): prove install env policy mutations * fix(cli): preflight plugin updates * fix(cli): preflight non-npm id migrations * chore(plugin-sdk): refresh api baseline * fix(cli): satisfy plugin recovery checks
This commit is contained in:
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { hashConfigIncludeRaw } from "../config/includes.js";
|
||||
import {
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
resolveOfficialExternalPluginId,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
loadConfig,
|
||||
loadPluginManifestRegistry,
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
parseClawHubPluginSpec,
|
||||
recordHookInstall,
|
||||
recordPluginInstall,
|
||||
@@ -231,6 +233,7 @@ function createHookPackInstallResult(targetDir: string): {
|
||||
ok: true;
|
||||
hookPackId: string;
|
||||
hooks: string[];
|
||||
packageKind: "hook-only";
|
||||
targetDir: string;
|
||||
version: string;
|
||||
} {
|
||||
@@ -238,6 +241,7 @@ function createHookPackInstallResult(targetDir: string): {
|
||||
ok: true,
|
||||
hookPackId: "demo-hooks",
|
||||
hooks: ["command-audit"],
|
||||
packageKind: "hook-only",
|
||||
targetDir,
|
||||
version: "1.2.3",
|
||||
};
|
||||
@@ -310,8 +314,10 @@ type PluginInstallCall = {
|
||||
dangerouslyForceUnsafeInstall?: boolean;
|
||||
dryRun?: boolean;
|
||||
expectedIntegrity?: string;
|
||||
expectedPackageKind?: "hook-only";
|
||||
expectedPluginId?: string;
|
||||
extensionsDir?: string;
|
||||
inspection?: "package-kind";
|
||||
logger?: {
|
||||
info?: unknown;
|
||||
warn?: unknown;
|
||||
@@ -399,6 +405,154 @@ function runtimeLogsContain(fragment: string): boolean {
|
||||
return runtimeLogs.some((line) => line.includes(fragment));
|
||||
}
|
||||
|
||||
function primeBlockedPluginConfigMutation(
|
||||
params: { blockHooks?: boolean; config?: OpenClawConfig } = {},
|
||||
): void {
|
||||
const configPath = path.join(process.cwd(), "openclaw.json5");
|
||||
const externalPluginsPath = path.join(
|
||||
path.parse(process.cwd()).root,
|
||||
"external-openclaw",
|
||||
"plugins.json5",
|
||||
);
|
||||
const externalHooksPath = path.join(
|
||||
path.parse(process.cwd()).root,
|
||||
"external-openclaw",
|
||||
"hooks.json5",
|
||||
);
|
||||
const config = params.config ?? ({} as OpenClawConfig);
|
||||
const parsed = {
|
||||
plugins: { $include: externalPluginsPath },
|
||||
...(params.blockHooks ? { hooks: { $include: externalHooksPath } } : {}),
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: JSON.stringify(parsed),
|
||||
parsed,
|
||||
resolved: config,
|
||||
sourceConfig: config,
|
||||
runtimeConfig: config,
|
||||
valid: true,
|
||||
config,
|
||||
hash: "blocked-plugin-config",
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
},
|
||||
writeOptions: {
|
||||
assertConfigPathForWrite: () => {},
|
||||
expectedConfigPath: configPath,
|
||||
ownedConfigPathForWrite: configPath,
|
||||
includeFileTargetsForWrite: {
|
||||
[externalPluginsPath]: externalPluginsPath,
|
||||
...(params.blockHooks ? { [externalHooksPath]: externalHooksPath } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function primeNestedPluginConfigMutation(tempRoot: string): void {
|
||||
const configPath = path.join(tempRoot, "openclaw.json5");
|
||||
const pluginsPath = path.join(tempRoot, "plugins.json5");
|
||||
const pluginsRaw = `${JSON.stringify({ entries: { $include: "./entries.json5" } }, null, 2)}\n`;
|
||||
const config = { plugins: { entries: {} } } as OpenClawConfig;
|
||||
fs.writeFileSync(pluginsPath, pluginsRaw);
|
||||
loadConfig.mockReturnValue(config);
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: JSON.stringify({ plugins: { $include: "./plugins.json5" } }),
|
||||
parsed: { plugins: { $include: "./plugins.json5" } },
|
||||
resolved: config,
|
||||
sourceConfig: config,
|
||||
runtimeConfig: config,
|
||||
valid: true,
|
||||
config,
|
||||
hash: "nested-plugin-config",
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
},
|
||||
writeOptions: {
|
||||
assertConfigPathForWrite: () => {},
|
||||
expectedConfigPath: configPath,
|
||||
ownedConfigPathForWrite: configPath,
|
||||
includeFileHashesForWrite: {
|
||||
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
|
||||
},
|
||||
includeFileTargetsForWrite: {
|
||||
[pluginsPath]: fs.realpathSync(pluginsPath),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function primeBlockedRootConfigMutation(config = {} as OpenClawConfig): void {
|
||||
const configPath = path.join(process.cwd(), "openclaw.json5");
|
||||
loadConfig.mockReturnValue(config);
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: JSON.stringify({ $include: "./shared.json5", plugins: {} }),
|
||||
parsed: { $include: "./shared.json5", plugins: {} },
|
||||
resolved: config,
|
||||
sourceConfig: config,
|
||||
runtimeConfig: config,
|
||||
valid: true,
|
||||
config,
|
||||
hash: "blocked-root-config",
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
},
|
||||
writeOptions: {
|
||||
assertConfigPathForWrite: () => {},
|
||||
expectedConfigPath: configPath,
|
||||
ownedConfigPathForWrite: configPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function primeBlockedHookConfigMutation(config = {} as OpenClawConfig): void {
|
||||
const configPath = path.join(process.cwd(), "openclaw.json5");
|
||||
const externalHooksPath = path.join(
|
||||
path.parse(process.cwd()).root,
|
||||
"external-openclaw",
|
||||
"hooks.json5",
|
||||
);
|
||||
const parsed = { hooks: { $include: externalHooksPath } };
|
||||
loadConfig.mockReturnValue(config);
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: JSON.stringify(parsed),
|
||||
parsed,
|
||||
resolved: config,
|
||||
sourceConfig: config,
|
||||
runtimeConfig: config,
|
||||
valid: true,
|
||||
config,
|
||||
hash: "blocked-hook-config",
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
},
|
||||
writeOptions: {
|
||||
assertConfigPathForWrite: () => {},
|
||||
expectedConfigPath: configPath,
|
||||
ownedConfigPathForWrite: configPath,
|
||||
includeFileTargetsForWrite: {
|
||||
[externalHooksPath]: externalHooksPath,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugins cli install", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginsCliTestState();
|
||||
@@ -445,6 +599,496 @@ describe("plugins cli install", () => {
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["@acme/demo-plugin", "npm:@acme/demo-plugin"])(
|
||||
"fails closed before installing blocked ambiguous npm plugin spec %s",
|
||||
async (spec) => {
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.hooks",
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", spec])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1);
|
||||
expect(hookNpmInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("installs a positively identified npm hook pack without probing plugin installation", async () => {
|
||||
const installedCfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/demo-hooks@1.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
hookPackId: "demo-hooks",
|
||||
hooks: ["command-audit"],
|
||||
packageKind: "hook-only",
|
||||
targetDir: "/tmp/hooks/demo-hooks",
|
||||
version: "1.2.3",
|
||||
npmResolution: {
|
||||
name: "@acme/demo-hooks",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "@acme/demo-hooks@1.2.3",
|
||||
integrity: "sha256-demo",
|
||||
},
|
||||
});
|
||||
recordHookInstall.mockReturnValue(installedCfg);
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]);
|
||||
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(2);
|
||||
expect(hookNpmInstallCall().inspection).toBe("package-kind");
|
||||
expect(hookNpmInstallCall(1).expectedIntegrity).toBe("sha256-demo");
|
||||
expect(hookNpmInstallCall(1).expectedPackageKind).toBe("hook-only");
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
});
|
||||
|
||||
it("blocks npm package inspection when plugin and hook config are include-owned", async () => {
|
||||
primeBlockedPluginConfigMutation({ blockHooks: true });
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
...createHookPackInstallResult("/tmp/hooks/demo-hooks"),
|
||||
npmResolution: {
|
||||
name: "@acme/demo-hooks",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "@acme/demo-hooks@1.2.3",
|
||||
integrity: "sha256-demo",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "@acme/demo-hooks"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config hooks are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks a proven npm hook pack before plugin installer side effects when only hooks config is include-owned", async () => {
|
||||
primeBlockedHookConfigMutation();
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
...createHookPackInstallResult("/tmp/hooks/demo-hooks"),
|
||||
npmResolution: {
|
||||
name: "@acme/demo-hooks",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "@acme/demo-hooks@1.2.3",
|
||||
integrity: "sha256-demo",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "@acme/demo-hooks"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1);
|
||||
expect(hookNpmInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config hooks are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks local package inspection when plugin and hook config are include-owned", async () => {
|
||||
const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-pack-"));
|
||||
primeBlockedPluginConfigMutation({ blockHooks: true });
|
||||
installHooksFromPath.mockResolvedValue(createHookPackInstallResult(localPath));
|
||||
installPluginFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.extensions",
|
||||
code: "missing_openclaw_extensions",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config hooks are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks a proven local hook pack before plugin installer side effects when only hooks config is include-owned", async () => {
|
||||
const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-pack-"));
|
||||
primeBlockedHookConfigMutation();
|
||||
installHooksFromPath.mockResolvedValue(createHookPackInstallResult(localPath));
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).toHaveBeenCalledTimes(1);
|
||||
expect(hookPathInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config hooks are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves local hook-pack precedence for prefix-shaped paths",
|
||||
async () => {
|
||||
const localPath = path.join(process.cwd(), `clawhub:demo-hooks-${process.pid}`);
|
||||
const installedCfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "path",
|
||||
sourcePath: localPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
fs.mkdirSync(localPath);
|
||||
primeBlockedPluginConfigMutation();
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo-hooks" });
|
||||
installPluginFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.extensions",
|
||||
code: "missing_openclaw_extensions",
|
||||
});
|
||||
installHooksFromPath.mockResolvedValue(createHookPackInstallResult(localPath));
|
||||
recordHookInstall.mockReturnValue(installedCfg);
|
||||
|
||||
try {
|
||||
await runPluginsCommand(["plugins", "install", path.basename(localPath)]);
|
||||
} finally {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(installHooksFromPath).toHaveBeenCalledTimes(2);
|
||||
expect(hookPathInstallCall().inspection).toBe("package-kind");
|
||||
expect(hookPathInstallCall(1).expectedPackageKind).toBe("hook-only");
|
||||
expect(installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed for ambiguous npm plugins when the whole config is include-owned", async () => {
|
||||
primeBlockedRootConfigMutation();
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.hooks",
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "@acme/demo-plugin"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain("unsupported $include shape at the root");
|
||||
});
|
||||
|
||||
it("fails closed for ambiguous local plugins when the whole config is include-owned", async () => {
|
||||
const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-demo-plugin-"));
|
||||
primeBlockedRootConfigMutation();
|
||||
installHooksFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.hooks",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).not.toHaveBeenCalled();
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain("unsupported $include shape at the root");
|
||||
});
|
||||
|
||||
it("fails closed before installing a blocked ambiguous local plugin", async () => {
|
||||
const archivePath = path.join(os.tmpdir(), `openclaw-plugin-${process.pid}.tgz`);
|
||||
fs.writeFileSync(archivePath, "not-an-archive");
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.hooks",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "install", archivePath])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(archivePath, { force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).toHaveBeenCalledTimes(1);
|
||||
expect(hookPathInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when an npm hook probe finds a plugin-capable package", async () => {
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
...createHookPackInstallResult("/tmp/hooks/demo-hooks"),
|
||||
packageKind: "plugin-capable",
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "@acme/dual-package"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1);
|
||||
expect(hookNpmInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a local hook probe finds a plugin-capable package", async () => {
|
||||
const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-dual-package-"));
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromPath.mockResolvedValue({
|
||||
...createHookPackInstallResult(localPath),
|
||||
packageKind: "plugin-capable",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).toHaveBeenCalledTimes(1);
|
||||
expect(hookPathInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed for a local bundle plugin instead of installing its hooks", async () => {
|
||||
const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundle-plugin-"));
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromPath.mockResolvedValue({
|
||||
...createHookPackInstallResult(localPath),
|
||||
packageKind: "plugin-capable",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).toHaveBeenCalledTimes(1);
|
||||
expect(hookPathInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a blocked-config npm hook probe throws", async () => {
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromNpmSpec.mockRejectedValue(new Error("hook validation exploded"));
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "@acme/demo-plugin"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1);
|
||||
expect(hookNpmInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a blocked-config local hook probe throws", async () => {
|
||||
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
|
||||
primeBlockedPluginConfigMutation();
|
||||
installHooksFromPath.mockRejectedValue(new Error("hook validation exploded"));
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "install", localPluginDir])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(localPluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).toHaveBeenCalledTimes(1);
|
||||
expect(hookPathInstallCall().inspection).toBe("package-kind");
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "marketplace",
|
||||
args: ["plugins", "install", "demo", "--marketplace", "local/repo"],
|
||||
installer: installPluginFromMarketplace,
|
||||
setup: () =>
|
||||
installPluginFromMarketplace.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: cliInstallPath("demo"),
|
||||
extensions: ["index.js"],
|
||||
version: "1.2.3",
|
||||
marketplaceName: "Claude",
|
||||
marketplaceSource: "local/repo",
|
||||
marketplacePlugin: "demo",
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "git",
|
||||
args: ["plugins", "install", "git:github.com/acme/demo"],
|
||||
installer: installPluginFromGitSpec,
|
||||
setup: () => installPluginFromGitSpec.mockResolvedValue(createGitPluginInstallResult()),
|
||||
},
|
||||
{
|
||||
label: "npm-pack",
|
||||
args: ["plugins", "install", "npm-pack:/tmp/demo.tgz"],
|
||||
installer: installPluginFromNpmPackArchive,
|
||||
setup: () =>
|
||||
installPluginFromNpmPackArchive.mockResolvedValue(createNpmPackPluginInstallResult()),
|
||||
},
|
||||
{
|
||||
label: "ClawHub",
|
||||
args: ["plugins", "install", "clawhub:demo"],
|
||||
installer: installPluginFromClawHub,
|
||||
setup: () => {
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue(
|
||||
createClawHubInstallResult({
|
||||
pluginId: "demo",
|
||||
packageName: "demo",
|
||||
version: "1.2.3",
|
||||
channel: "stable",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
])(
|
||||
"blocks explicit $label plugin installs before installer side effects",
|
||||
async ({ args, installer, setup }) => {
|
||||
primeBlockedPluginConfigMutation();
|
||||
setup();
|
||||
|
||||
await expect(runPluginsCommand(args)).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installer).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks bare official plugins before installer side effects", async () => {
|
||||
primeBlockedPluginConfigMutation();
|
||||
findBundledPluginSourceMock.mockReturnValue(undefined);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "brave"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks bare bundled plugin ids before installer side effects", async () => {
|
||||
const pluginId = "config-required-plugin";
|
||||
primeBlockedPluginConfigMutation();
|
||||
findBundledPluginSourceMock.mockReturnValue({
|
||||
pluginId,
|
||||
localPath: `/app/dist/extensions/${pluginId}`,
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", pluginId])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installPluginFromPath).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks explicit plugins through nested include config before installer side effects", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-nested-"));
|
||||
primeNestedPluginConfigMutation(tempRoot);
|
||||
installPluginFromMarketplace.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: cliInstallPath("demo"),
|
||||
extensions: ["index.js"],
|
||||
version: "1.2.3",
|
||||
marketplaceName: "Claude",
|
||||
marketplaceSource: "local/repo",
|
||||
marketplacePlugin: "demo",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "install", "demo", "--marketplace", "local/repo"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain("nested $include");
|
||||
});
|
||||
|
||||
it("exits when --marketplace is combined with --link", async () => {
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]),
|
||||
|
||||
Reference in New Issue
Block a user