Files
openclaw/src/cli/plugins-install-config.test.ts
Vincent Koc 767e8280ac 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
2026-06-15 23:07:29 +08:00

961 lines
34 KiB
TypeScript

// Plugin install config tests cover install specs and generated plugin config.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { bundledPluginRootAt, repoInstallSpec } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { hashConfigIncludeRaw } from "../config/includes.js";
import type { ConfigWriteOptions } from "../config/io.js";
import type { ConfigFileSnapshot } from "../config/types.openclaw.js";
import {
resolvePluginInstallRequestContext,
type PluginInstallRequestContext,
} from "./plugin-install-config-policy.js";
import { loadConfigForInstall } from "./plugins-install-command.js";
const hoisted = vi.hoisted(() => ({
assertConfigPathForWriteMock: vi.fn(),
includeFileHashesForWriteMock: vi.fn<() => Record<string, string>>(),
includeFileTargetsForWriteMock: vi.fn<() => Record<string, string>>(),
readConfigFileSnapshotMock: vi.fn<() => Promise<ConfigFileSnapshot>>(),
loadInstalledPluginIndexInstallRecordsMock: vi.fn(),
listPersistedBundledPluginRecoveryLocationsMock: vi.fn(),
}));
const readConfigFileSnapshotMock = hoisted.readConfigFileSnapshotMock;
const assertConfigPathForWriteMock = hoisted.assertConfigPathForWriteMock;
const includeFileHashesForWriteMock = hoisted.includeFileHashesForWriteMock;
const includeFileTargetsForWriteMock = hoisted.includeFileTargetsForWriteMock;
const loadInstalledPluginIndexInstallRecordsMock =
hoisted.loadInstalledPluginIndexInstallRecordsMock;
const listPersistedBundledPluginRecoveryLocationsMock =
hoisted.listPersistedBundledPluginRecoveryLocationsMock;
vi.mock("../config/config.js", () => ({
readConfigFileSnapshotForWrite: async () => ({
snapshot: await readConfigFileSnapshotMock(),
writeOptions: {
assertConfigPathForWrite: assertConfigPathForWriteMock,
basePluginMetadataSnapshot: {} as never,
expectedConfigPath: "/tmp/config.json5",
ownedConfigPathForWrite: "/tmp/config.json5",
includeFileHashesForWrite: includeFileHashesForWriteMock(),
includeFileTargetsForWrite: includeFileTargetsForWriteMock(),
},
}),
}));
vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../plugins/installed-plugin-index-records.js")>();
return {
...actual,
loadInstalledPluginIndexInstallRecords: () => loadInstalledPluginIndexInstallRecordsMock(),
};
});
vi.mock("./plugins-location-bridges.js", () => ({
listPersistedBundledPluginRecoveryLocations: () =>
listPersistedBundledPluginRecoveryLocationsMock(),
}));
const DISCORD_REPO_INSTALL_SPEC = repoInstallSpec("discord");
const installWriteOptions = {
assertConfigPathForWrite: assertConfigPathForWriteMock,
expectedConfigPath: "/tmp/config.json5",
ownedConfigPathForWrite: "/tmp/config.json5",
includeFileHashesForWrite: { "/tmp/plugins.json5": "include-1" },
includeFileTargetsForWrite: { "/tmp/plugins.json5": "/tmp/plugins.json5" },
} satisfies ConfigWriteOptions;
function makeSnapshot(overrides: Partial<ConfigFileSnapshot> = {}): ConfigFileSnapshot {
return {
path: "/tmp/config.json5",
exists: true,
raw: '{ "plugins": {} }',
parsed: { plugins: {} },
sourceConfig: { plugins: {} } as ConfigFileSnapshot["sourceConfig"],
resolved: { plugins: {} } as OpenClawConfig,
valid: false,
runtimeConfig: { plugins: {} } as ConfigFileSnapshot["runtimeConfig"],
config: { plugins: {} } as OpenClawConfig,
hash: "abc",
issues: [{ path: "plugins.installs.discord", message: "stale path" }],
warnings: [],
legacyIssues: [],
...overrides,
};
}
describe("loadConfigForInstall", () => {
const discordNpmRequest = {
rawSpec: "@openclaw/discord",
normalizedSpec: "@openclaw/discord",
installKind: "plugin",
bundledPluginId: "discord",
allowInvalidConfigRecovery: true,
} satisfies PluginInstallRequestContext;
beforeEach(() => {
readConfigFileSnapshotMock.mockReset();
includeFileHashesForWriteMock.mockReset();
includeFileTargetsForWriteMock.mockReset();
loadInstalledPluginIndexInstallRecordsMock.mockReset();
listPersistedBundledPluginRecoveryLocationsMock.mockReset();
loadInstalledPluginIndexInstallRecordsMock.mockResolvedValue({});
includeFileHashesForWriteMock.mockReturnValue({
"/tmp/plugins.json5": "include-1",
});
includeFileTargetsForWriteMock.mockReturnValue({
"/tmp/plugins.json5": "/tmp/plugins.json5",
});
listPersistedBundledPluginRecoveryLocationsMock.mockResolvedValue([]);
});
it("returns the source config and base hash when the snapshot is valid", async () => {
const cfg = { plugins: { entries: { discord: { enabled: true } } } } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
sourceConfig: cfg,
config: { plugins: { entries: { discord: { enabled: true } }, enabled: true } },
hash: "config-1",
issues: [],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(result).toEqual({
config: cfg,
baseHash: "config-1",
writeOptions: installWriteOptions,
hookMutation: { mode: "allowed" },
pluginMutation: { mode: "allowed" },
});
});
it("returns valid source config unchanged", async () => {
const cfg = { plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
sourceConfig: cfg,
config: cfg,
issues: [],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(result.config).toBe(cfg);
});
it("falls back to snapshot config for explicit bundled-plugin reinstall when issues match the known upgrade failure", async () => {
const snapshotCfg = {
plugins: { installs: { discord: { source: "path", installPath: "/gone" } } },
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { discord: {} } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({
config: snapshotCfg,
baseHash: "abc",
writeOptions: installWriteOptions,
hookMutation: { mode: "allowed" },
pluginMutation: { mode: "allowed" },
});
});
it("allows versioned npm:-prefixed bundled-plugin reinstall recovery", async () => {
const snapshotCfg = {
plugins: { installs: { discord: { source: "path", installPath: "/gone" } } },
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { discord: {} } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
const request = resolvePluginInstallRequestContext({
rawSpec: "npm:@openclaw/discord@2026.5.22",
});
if (!request.ok) {
throw new Error(request.error);
}
expect(request.request.bundledPluginId).toBe("discord");
expect(request.request.allowInvalidConfigRecovery).toBe(true);
const result = await loadConfigForInstall(request.request);
expect(result).toEqual({
config: snapshotCfg,
baseHash: "abc",
writeOptions: installWriteOptions,
hookMutation: { mode: "allowed" },
pluginMutation: { mode: "allowed" },
});
});
it.each(["file:@openclaw/discord", "FILE:@openclaw/discord"])(
"does not treat %s as an official plugin recovery request",
(rawSpec) => {
const request = resolvePluginInstallRequestContext({ rawSpec });
if (!request.ok) {
throw new Error(request.error);
}
expect(request.request.bundledPluginId).toBeUndefined();
expect(request.request.allowInvalidConfigRecovery).toBeUndefined();
},
);
it("preserves a caller-proven plugin-only install kind", () => {
const request = resolvePluginInstallRequestContext({
rawSpec: "clawhub:demo",
installKind: "plugin",
});
if (!request.ok) {
throw new Error(request.error);
}
expect(request.request.installKind).toBe("plugin");
});
it("allows versioned official npm spec reinstall recovery", async () => {
const snapshotCfg = {
plugins: {
installs: { discord: { source: "npm", installPath: "/gone" } },
load: { paths: ["/gone", "/keep"] },
},
channels: { discord: { token: "preserve-me" } },
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { discord: {} }, load: { paths: ["/gone", "/keep"] } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
const request = resolvePluginInstallRequestContext({
rawSpec: "@openclaw/discord@2026.5.22",
});
if (!request.ok) {
throw new Error(request.error);
}
expect(request.request.bundledPluginId).toBe("discord");
expect(request.request.allowInvalidConfigRecovery).toBe(true);
const result = await loadConfigForInstall(request.request);
expect(result).toEqual({
config: {
plugins: {
installs: { discord: { source: "npm", installPath: "/gone" } },
load: { paths: ["/keep"] },
},
channels: { discord: { token: "preserve-me" } },
},
baseHash: "abc",
writeOptions: installWriteOptions,
hookMutation: { mode: "allowed" },
pluginMutation: { mode: "allowed" },
});
});
it("uses the canonical plugin install record to own a stale recovery load path", async () => {
const snapshotCfg = {
plugins: { load: { paths: ["/gone", "/keep"] } },
} as unknown as OpenClawConfig;
loadInstalledPluginIndexInstallRecordsMock.mockResolvedValue({
discord: { source: "npm", installPath: "/gone" },
});
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { load: { paths: ["/gone", "/keep"] } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(result.config.plugins?.load?.paths).toEqual(["/keep"]);
});
it("does not let a stale legacy install record override the canonical record", async () => {
const snapshotCfg = {
plugins: {
installs: { discord: { source: "npm", installPath: "/gone" } },
load: { paths: ["/gone"] },
},
} as unknown as OpenClawConfig;
loadInstalledPluginIndexInstallRecordsMock.mockResolvedValue({
discord: { source: "npm", installPath: "/canonical" },
});
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: {
plugins: {
installs: { discord: { source: "npm", installPath: "/gone" } },
load: { paths: ["/gone"] },
},
},
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config invalid outside the plugin recovery path for discord",
);
});
it("uses a persisted externalization bridge to own a stale bundled load path", async () => {
const staleBundledPath = "/app/extensions/discord";
const snapshotCfg = {
plugins: { load: { paths: [staleBundledPath, "/keep"] } },
} as unknown as OpenClawConfig;
listPersistedBundledPluginRecoveryLocationsMock.mockResolvedValue([
{
pluginId: "discord",
loadPaths: [staleBundledPath],
},
]);
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { load: { paths: [staleBundledPath, "/keep"] } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{
path: "plugins.load.paths",
message: `plugin: plugin path not found: ${staleBundledPath}`,
},
],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(result.config.plugins?.load?.paths).toEqual(["/keep"]);
});
it("rejects recovery rather than removing another plugin's missing load path", async () => {
const operatorCheckoutPath = "/workspace/extensions/discord";
const snapshotCfg = {
plugins: {
load: { paths: [operatorCheckoutPath] },
},
} as unknown as OpenClawConfig;
listPersistedBundledPluginRecoveryLocationsMock.mockResolvedValue([
{
pluginId: "discord",
loadPaths: ["/app/extensions/discord"],
},
]);
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: {
plugins: {
load: { paths: [operatorCheckoutPath] },
},
},
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{
path: "plugins.load.paths",
message: `plugin: plugin path not found: ${operatorCheckoutPath}`,
},
],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config invalid outside the plugin recovery path for discord",
);
});
it("rejects malformed install record paths without crashing recovery", async () => {
const snapshotCfg = {
plugins: {
installs: { discord: { source: "npm", installPath: 1 } },
load: { paths: ["/gone"] },
},
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { discord: {} }, load: { paths: ["/gone"] } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config invalid outside the plugin recovery path for discord",
);
});
it("rejects unattributed source-only runtime failures during official plugin recovery", async () => {
const snapshotCfg = {
plugins: { installs: { discord: { source: "npm", installPath: "/bad/discord" } } },
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { discord: {} } } },
config: snapshotCfg,
issues: [
{
path: "plugins",
message:
"plugin: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js, ./dist/index.mjs, ./dist/index.cjs, index.js, index.mjs, index.cjs. This is a plugin packaging issue, not a local config problem; update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then. TypeScript source fallback is only supported for source checkouts and local development paths.",
},
],
}),
);
const request = resolvePluginInstallRequestContext({
rawSpec: "npm:@openclaw/discord",
});
if (!request.ok) {
throw new Error(request.error);
}
await expect(loadConfigForInstall(request.request)).rejects.toThrow(
"Config invalid outside the plugin recovery path for discord",
);
});
it("allows Brave official plugin reinstall recovery from source-only runtime shadows", async () => {
const snapshotCfg = {
plugins: { installs: { brave: { source: "clawhub", installPath: "/bad/brave" } } },
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { brave: {} } } },
config: snapshotCfg,
issues: [
{
path: "plugins.entries.brave",
message:
"plugin brave: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js.",
},
{
path: "tools.web.search.provider",
message:
'web_search provider is not available: brave (install or enable plugin "brave", then run openclaw doctor --fix)',
},
],
}),
);
const request = resolvePluginInstallRequestContext({
rawSpec: "@openclaw/brave-plugin",
});
if (!request.ok) {
throw new Error(request.error);
}
expect(request.request.allowInvalidConfigRecovery).toBe(true);
const result = await loadConfigForInstall(request.request);
expect(result).toEqual({
config: snapshotCfg,
baseHash: "abc",
writeOptions: installWriteOptions,
hookMutation: { mode: "allowed" },
pluginMutation: { mode: "allowed" },
});
});
it("allows explicit repo-checkout bundled-plugin reinstall recovery", async () => {
const snapshotCfg = { plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
config: snapshotCfg,
issues: [{ path: "channels.discord", message: "unknown channel id: discord" }],
}),
);
const repoRequest = resolvePluginInstallRequestContext({
rawSpec: DISCORD_REPO_INSTALL_SPEC,
});
if (!repoRequest.ok) {
throw new Error(repoRequest.error);
}
const result = await loadConfigForInstall({
...repoRequest.request,
resolvedPath: bundledPluginRootAt("/tmp/repo", "discord"),
});
expect(result.config).toBe(snapshotCfg);
});
it("allows recovery through an exact single-file top-level plugins include", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-include-"));
const configPath = path.join(tempRoot, "config.json5");
const pluginsPath = path.join(tempRoot, "plugins.json5");
const pluginsRaw = `${JSON.stringify({ entries: {} }, null, 2)}\n`;
fs.writeFileSync(pluginsPath, pluginsRaw);
includeFileHashesForWriteMock.mockReturnValue({
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
});
includeFileTargetsForWriteMock.mockReturnValue({
[pluginsPath]: fs.realpathSync(pluginsPath),
});
const snapshotCfg = { plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
path: configPath,
parsed: { plugins: { $include: "./plugins.json5" } },
config: snapshotCfg,
issues: [{ path: "channels.discord", message: "unknown channel id: discord" }],
}),
);
try {
const result = await loadConfigForInstall(discordNpmRequest);
expect(result.config).toBe(snapshotCfg);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rejects recovery installs through an external plugins include", async () => {
const externalPluginsPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
"plugins.json5",
);
const snapshotCfg = { plugins: {} } as OpenClawConfig;
includeFileTargetsForWriteMock.mockReturnValue({
[externalPluginsPath]: externalPluginsPath,
});
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { $include: externalPluginsPath } },
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [{ path: "channels.discord", message: "unknown channel id: discord" }],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config plugins are stored in an external or unresolved top-level $include",
);
});
it("rejects external include aliases even when their target is under the config directory", async () => {
const configPath = path.join(process.cwd(), "config.json5");
const externalPluginsPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
"plugins.json5",
);
includeFileTargetsForWriteMock.mockReturnValue({
[externalPluginsPath]: path.join(fs.realpathSync(process.cwd()), "plugins.json5"),
});
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
path: configPath,
parsed: { plugins: { $include: externalPluginsPath } },
config: { plugins: {} } as OpenClawConfig,
issues: [{ path: "channels.discord", message: "unknown channel id: discord" }],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config plugins are stored in an external or unresolved top-level $include",
);
});
it("carries a plugin-mutation block for ambiguous installs through external plugin includes", async () => {
const externalPluginsPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
"plugins.json5",
);
const snapshotCfg = { plugins: {} } as OpenClawConfig;
includeFileTargetsForWriteMock.mockReturnValue({
[externalPluginsPath]: externalPluginsPath,
});
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
parsed: { plugins: { $include: externalPluginsPath } },
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
const result = await loadConfigForInstall({
rawSpec: "maybe-hook-pack",
normalizedSpec: "maybe-hook-pack",
});
expect(result.config).toBe(snapshotCfg);
expect(result.pluginMutation).toEqual({
mode: "blocked",
scope: "plugins",
reason: expect.stringContaining("external or unresolved top-level $include"),
});
});
it("blocks known plugins through external includes", async () => {
const externalPluginsPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
"plugins.json5",
);
const snapshotCfg = { plugins: {} } as OpenClawConfig;
includeFileTargetsForWriteMock.mockReturnValue({
[externalPluginsPath]: externalPluginsPath,
});
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
parsed: { plugins: { $include: externalPluginsPath } },
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"external or unresolved top-level $include",
);
});
it("carries a hook-mutation block through an external hooks include", async () => {
const externalHooksPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
"hooks.json5",
);
const snapshotCfg = { hooks: { internal: {} } } as OpenClawConfig;
includeFileTargetsForWriteMock.mockReturnValue({
[externalHooksPath]: externalHooksPath,
});
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
parsed: { hooks: { $include: externalHooksPath } },
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
const result = await loadConfigForInstall({
rawSpec: "maybe-hook-pack",
normalizedSpec: "maybe-hook-pack",
});
expect(result.hookMutation).toEqual({
mode: "blocked",
scope: "hooks",
reason: expect.stringContaining("external or unresolved top-level $include"),
});
expect(result.pluginMutation).toEqual({ mode: "allowed" });
});
it("blocks config mutations when plugins and hooks share one canonical include target", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shared-include-"));
const configPath = path.join(tempRoot, "config.json5");
const sharedPath = path.join(tempRoot, "shared.json5");
const sharedRaw = "{}\n";
fs.writeFileSync(sharedPath, sharedRaw);
includeFileHashesForWriteMock.mockReturnValue({
[sharedPath]: hashConfigIncludeRaw(sharedRaw),
});
includeFileTargetsForWriteMock.mockReturnValue({
[sharedPath]: fs.realpathSync(sharedPath),
});
const snapshotCfg = { hooks: {}, plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
path: configPath,
valid: true,
parsed: {
hooks: { $include: "./shared.json5" },
plugins: { $include: "./shared.json5" },
},
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
try {
const result = await loadConfigForInstall({
rawSpec: "maybe-hook-pack",
normalizedSpec: "maybe-hook-pack",
});
expect(result.hookMutation).toEqual({
mode: "blocked",
scope: "config",
reason: expect.stringContaining("share the same top-level $include target"),
});
expect(result.pluginMutation).toEqual(result.hookMutation);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"share the same top-level $include target",
);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("blocks both mutations when an external include aliases the other section target", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-aliased-include-"));
const configPath = path.join(tempRoot, "config.json5");
const sharedPath = path.join(tempRoot, "shared.json5");
const externalHooksPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
"hooks.json5",
);
const sharedRaw = "{}\n";
fs.writeFileSync(sharedPath, sharedRaw);
includeFileHashesForWriteMock.mockReturnValue({
[sharedPath]: hashConfigIncludeRaw(sharedRaw),
});
includeFileTargetsForWriteMock.mockReturnValue({
[sharedPath]: fs.realpathSync(sharedPath),
[externalHooksPath]: fs.realpathSync(sharedPath),
});
const snapshotCfg = { hooks: {}, plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
path: configPath,
valid: true,
parsed: {
hooks: { $include: externalHooksPath },
plugins: { $include: "./shared.json5" },
},
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
try {
const result = await loadConfigForInstall({
rawSpec: "maybe-hook-pack",
normalizedSpec: "maybe-hook-pack",
});
expect(result.hookMutation).toEqual({
mode: "blocked",
scope: "config",
reason: expect.stringContaining("share the same top-level $include target"),
});
expect(result.pluginMutation).toEqual(result.hookMutation);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"share the same top-level $include target",
);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("blocks nested plugins includes before plugin installation", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-nested-include-"));
const configPath = path.join(tempRoot, "config.json5");
const pluginsPath = path.join(tempRoot, "plugins.json5");
const pluginsRaw = `${JSON.stringify({ entries: { $include: "./entries.json5" } }, null, 2)}\n`;
fs.writeFileSync(pluginsPath, pluginsRaw);
includeFileHashesForWriteMock.mockReturnValue({
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
});
includeFileTargetsForWriteMock.mockReturnValue({
[pluginsPath]: fs.realpathSync(pluginsPath),
});
const snapshotCfg = { plugins: { entries: {} } } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
path: configPath,
valid: true,
parsed: { plugins: { $include: "./plugins.json5" } },
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
try {
const ambiguousResult = await loadConfigForInstall({
rawSpec: "maybe-hook-pack",
normalizedSpec: "maybe-hook-pack",
});
expect(ambiguousResult.pluginMutation).toEqual({
mode: "blocked",
scope: "plugins",
reason: expect.stringContaining("nested $include"),
});
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow("nested $include");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
const unsupportedPluginIncludeShapes = [
{
label: "plugins include array",
parsed: { plugins: { $include: ["./plugins-a.json5", "./plugins-b.json5"] } },
scope: "plugins",
},
{
label: "plugins include with siblings",
parsed: { plugins: { $include: "./plugins.json5", entries: {} } },
scope: "plugins",
},
{
label: "nested plugins include",
parsed: { plugins: { entries: { $include: "./entries.json5" } } },
scope: "plugins",
},
{
label: "root include without authored plugins",
parsed: { $include: "./root.json5" },
scope: "config",
},
{
label: "root include with authored plugins",
parsed: { $include: "./root.json5", plugins: { entries: {} } },
scope: "config",
},
] as const;
it.each(unsupportedPluginIncludeShapes)(
"rejects recovery through an unsupported $label",
async ({ parsed }) => {
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed,
config: { plugins: {} } as OpenClawConfig,
issues: [{ path: "channels.discord", message: "unknown channel id: discord" }],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config plugin recovery uses an unsupported $include shape",
);
},
);
it.each(unsupportedPluginIncludeShapes)(
"marks valid ambiguous installs through an unsupported $label as plugin-blocked",
async ({ parsed, scope }) => {
const snapshotCfg = { plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
parsed,
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
const result = await loadConfigForInstall({
rawSpec: "maybe-hook-pack",
normalizedSpec: "maybe-hook-pack",
});
expect(result.pluginMutation).toEqual({
mode: "blocked",
scope,
reason: expect.stringContaining("unsupported $include shape"),
});
},
);
it.each(unsupportedPluginIncludeShapes)(
"blocks valid known plugins through an unsupported $label",
async ({ parsed }) => {
const snapshotCfg = { plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
parsed,
sourceConfig: snapshotCfg as ConfigFileSnapshot["sourceConfig"],
config: snapshotCfg,
issues: [],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"unsupported $include shape",
);
},
);
it("rejects unrelated invalid config even during bundled-plugin reinstall recovery", async () => {
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
issues: [{ path: "models.default", message: "invalid model ref" }],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config invalid outside the plugin recovery path for discord",
);
});
it("rejects non-Discord install requests when config is invalid", async () => {
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
await expect(
loadConfigForInstall({
rawSpec: "alpha",
normalizedSpec: "alpha",
}),
).rejects.toThrow("Config invalid; run `openclaw doctor --fix` before installing plugins.");
});
it("throws when invalid snapshot parsed is empty", async () => {
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: {},
config: {} as OpenClawConfig,
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config file could not be parsed; run `openclaw doctor` to repair it.",
);
});
it("throws when invalid snapshot config file does not exist", async () => {
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot({ exists: false, parsed: {} }));
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config file could not be parsed; run `openclaw doctor` to repair it.",
);
});
});