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:
Vincent Koc
2026-06-15 23:07:29 +08:00
committed by GitHub
parent c1219d161d
commit 767e8280ac
39 changed files with 9380 additions and 898 deletions

View File

@@ -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"]),