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

@@ -22,6 +22,7 @@ type PluginInstallInvalidConfigPolicy = "deny" | "allow-plugin-recovery";
export type PluginInstallRequestContext = {
rawSpec: string;
normalizedSpec: string;
installKind?: "plugin";
resolvedPath?: string;
marketplace?: string;
bundledPluginId?: string;
@@ -77,6 +78,12 @@ function resolveBundledInstallRecoveryMetadata(
return direct;
}
}
if (
resolveFileNpmSpecToLocalPath(request.rawSpec) !== null ||
(request.resolvedPath !== undefined && fs.existsSync(request.resolvedPath))
) {
return {};
}
const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec);
const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec);
for (const value of [
@@ -104,7 +111,7 @@ function resolveBundledInstallRecoveryMetadata(
}
function resolveOfficialExternalInstallRecoveryMetadata(
request: Pick<PluginInstallRequestContext, "rawSpec" | "marketplace">,
request: Pick<PluginInstallRequestContext, "rawSpec" | "normalizedSpec" | "marketplace">,
): {
pluginId?: string;
allowInvalidConfigRecovery?: boolean;
@@ -112,19 +119,24 @@ function resolveOfficialExternalInstallRecoveryMetadata(
if (request.marketplace) {
return {};
}
if (request.rawSpec.trim().startsWith("file:")) {
if (resolveFileNpmSpecToLocalPath(request.rawSpec) !== null) {
return {};
}
if (fs.existsSync(resolveUserPath(request.rawSpec))) {
return {};
}
const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec);
const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec);
const values = new Set(
normalizeStringEntries([
request.rawSpec,
request.normalizedSpec,
rawNpmPrefixSpec ?? "",
normalizedNpmPrefixSpec ?? "",
parseRegistryNpmSpec(request.rawSpec)?.name ?? "",
parseRegistryNpmSpec(request.normalizedSpec)?.name ?? "",
rawNpmPrefixSpec ? parseRegistryNpmSpec(rawNpmPrefixSpec)?.name : "",
normalizedNpmPrefixSpec ? parseRegistryNpmSpec(normalizedNpmPrefixSpec)?.name : "",
]),
);
if (values.size === 0) {
@@ -193,6 +205,7 @@ function resolvePluginInstallArgvRequest(commandPath: string[], argv: string[])
export function resolvePluginInstallRequestContext(params: {
rawSpec: string;
marketplace?: string;
installKind?: "plugin";
}): PluginInstallRequestResolution {
if (params.marketplace) {
return {
@@ -200,6 +213,7 @@ export function resolvePluginInstallRequestContext(params: {
request: {
rawSpec: params.rawSpec,
normalizedSpec: params.rawSpec,
installKind: "plugin",
marketplace: params.marketplace,
},
};
@@ -220,6 +234,7 @@ export function resolvePluginInstallRequestContext(params: {
});
const officialRecovered = resolveOfficialExternalInstallRecoveryMetadata({
rawSpec: params.rawSpec,
normalizedSpec,
marketplace: params.marketplace,
});
const recovered =
@@ -232,6 +247,7 @@ export function resolvePluginInstallRequestContext(params: {
rawSpec: params.rawSpec,
normalizedSpec,
resolvedPath: resolveUserPath(normalizedSpec),
...(params.installKind === "plugin" || recovered.pluginId ? { installKind: "plugin" } : {}),
...(recovered.pluginId ? { bundledPluginId: recovered.pluginId } : {}),
...(recovered.allowInvalidConfigRecovery !== undefined
? { allowInvalidConfigRecovery: recovered.allowInvalidConfigRecovery }

View File

@@ -21,6 +21,10 @@ type ListMarketplacePluginsFn =
(typeof import("../plugins/marketplace.js"))["listMarketplacePlugins"];
type ResolveMarketplaceInstallShortcutFn =
(typeof import("../plugins/marketplace.js"))["resolveMarketplaceInstallShortcut"];
type UpdateNpmInstalledPluginsFn =
(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"];
type UpdateNpmInstalledHookPacksFn =
(typeof import("../hooks/update.js"))["updateNpmInstalledHookPacks"];
type PluginInstallRecordMap = Record<string, PluginInstallRecord>;
let mockInstalledPluginIndexInstallRecords: PluginInstallRecordMap = {};
@@ -37,6 +41,7 @@ function invokeMock<TArgs extends unknown[], TResult>(mock: unknown, ...args: TA
export const loadConfig: Mock<LoadConfigFn> = vi.fn<LoadConfigFn>(() => ({}) as OpenClawConfig);
export const readConfigFileSnapshot: AsyncUnknownMock = vi.fn();
export const readConfigFileSnapshotForWrite: AsyncUnknownMock = vi.fn();
export const writeConfigFile: AsyncUnknownMock = vi.fn(async () => undefined);
export const replaceConfigFile: AsyncUnknownMock = vi.fn(
async (params: { nextConfig: OpenClawConfig }) => await writeConfigFile(params.nextConfig),
@@ -73,8 +78,8 @@ export const applyExclusiveSlotSelection: UnknownMock = vi.fn();
export const planPluginUninstall: UnknownMock = vi.fn();
export const applyPluginUninstallDirectoryRemoval: AsyncUnknownMock = vi.fn();
const uninstallPlugin: AsyncUnknownMock = vi.fn();
export const updateNpmInstalledPlugins: AsyncUnknownMock = vi.fn();
export const updateNpmInstalledHookPacks: AsyncUnknownMock = vi.fn();
export const updateNpmInstalledPlugins: Mock<UpdateNpmInstalledPluginsFn> = vi.fn();
export const updateNpmInstalledHookPacks: Mock<UpdateNpmInstalledHookPacksFn> = vi.fn();
export const promptYesNo: AsyncUnknownMock = vi.fn();
export class PromptInputClosedError extends Error {
constructor() {
@@ -191,6 +196,16 @@ vi.mock("../config/config.js", () => ({
readConfigFileSnapshot,
...args,
)) as (typeof import("../config/config.js"))["readConfigFileSnapshot"],
readConfigFileSnapshotForWrite: ((
...args: Parameters<(typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"]>
) =>
invokeMock<
Parameters<(typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"]>,
ReturnType<(typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"]>
>(
readConfigFileSnapshotForWrite,
...args,
)) as (typeof import("../config/config.js"))["readConfigFileSnapshotForWrite"],
writeConfigFile: ((config: OpenClawConfig) =>
invokeMock<
[OpenClawConfig],
@@ -481,18 +496,22 @@ vi.mock("../plugins/uninstall.js", async (importOriginal) => {
};
});
vi.mock("../plugins/update.js", () => ({
updateNpmInstalledPlugins: ((
...args: Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>
) =>
invokeMock<
Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>,
ReturnType<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>
>(
updateNpmInstalledPlugins,
...args,
)) as (typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"],
}));
vi.mock("../plugins/update.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/update.js")>();
return {
...actual,
updateNpmInstalledPlugins: ((
...args: Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>
) =>
invokeMock<
Parameters<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>,
ReturnType<(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"]>
>(
updateNpmInstalledPlugins,
...args,
)) as (typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"],
};
});
vi.mock("../hooks/update.js", () => ({
updateNpmInstalledHookPacks: ((
@@ -679,6 +698,7 @@ export function resetPluginsCliTestState() {
restoreRuntimeCaptureMocks();
loadConfig.mockReset();
readConfigFileSnapshot.mockReset();
readConfigFileSnapshotForWrite.mockReset();
writeConfigFile.mockReset();
replaceConfigFile.mockReset();
resolveStateDir.mockReset();
@@ -737,6 +757,17 @@ export function resetPluginsCliTestState() {
legacyIssues: [],
};
});
readConfigFileSnapshotForWrite.mockImplementation(async () => {
const snapshot = (await readConfigFileSnapshot()) as { path: string };
return {
snapshot,
writeOptions: {
assertConfigPathForWrite: () => {},
expectedConfigPath: snapshot.path,
ownedConfigPathForWrite: snapshot.path,
},
};
});
writeConfigFile.mockResolvedValue(undefined);
replaceConfigFile.mockImplementation(
(async (params: { nextConfig: OpenClawConfig }) =>

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

View File

@@ -1,11 +1,17 @@
// Plugins CLI update tests cover plugin update command behavior and output.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { hashConfigIncludeRaw } from "../config/includes.js";
import {
loadConfig,
readConfigFileSnapshotForWrite,
refreshPluginRegistry,
registerPluginsCli,
replaceConfigFile,
resetPluginsCliTestState,
runPluginsCommand,
runtimeErrors,
@@ -55,6 +61,63 @@ function expectSingleCallParams(mockFn: ReturnType<typeof vi.fn>) {
return params;
}
function primeUpdateConfigSnapshot(params: {
config: OpenClawConfig;
configPath?: string;
loadedConfig?: OpenClawConfig;
parsed?: Record<string, unknown>;
runtimeConfig?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
valid?: boolean;
includeFileHashesForWrite?: Record<string, string>;
includeFileTargetsForWrite?: Record<string, string>;
}): void {
const configPath = params.configPath ?? path.join(process.cwd(), "openclaw.json5");
const parsed = params.parsed ?? (params.config as Record<string, unknown>);
const sourceConfig = params.sourceConfig ?? params.config;
const runtimeConfig = params.runtimeConfig ?? params.config;
loadConfig.mockReturnValue(params.loadedConfig ?? params.config);
readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: {
path: configPath,
exists: true,
raw: JSON.stringify(parsed),
parsed,
resolved: sourceConfig,
sourceConfig,
runtimeConfig,
valid: params.valid ?? true,
config: runtimeConfig,
hash: "update-config",
issues: [],
warnings: [],
legacyIssues: [],
},
writeOptions: {
assertConfigPathForWrite: () => {},
expectedConfigPath: configPath,
ownedConfigPathForWrite: configPath,
includeFileHashesForWrite: params.includeFileHashesForWrite,
includeFileTargetsForWrite: params.includeFileTargetsForWrite,
},
});
}
function primeBlockedUpdateConfig(section: "hooks" | "plugins", config: OpenClawConfig): void {
const externalPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
`${section}.json5`,
);
primeUpdateConfigSnapshot({
config,
parsed: { [section]: { $include: externalPath } },
includeFileTargetsForWrite: {
[externalPath]: externalPath,
},
});
}
describe("plugins cli update", () => {
beforeEach(() => {
resetPluginsCliTestState();
@@ -131,7 +194,12 @@ describe("plugins cli update", () => {
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
primeUpdateConfigSnapshot({
config: cfg,
includeFileHashesForWrite: {
"/tmp/hooks.json5": "hooks-start-hash",
},
});
updateNpmInstalledPlugins.mockResolvedValue({
config: cfg,
changed: false,
@@ -155,10 +223,682 @@ describe("plugins cli update", () => {
expect(hookUpdateParams.config).toBe(cfg);
expect(hookUpdateParams.hookIds).toEqual(["demo-hooks"]);
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
expect(replaceConfigFile).toHaveBeenCalledWith({
nextConfig,
baseHash: "update-config",
writeOptions: expect.objectContaining({
includeFileHashesForWrite: {
"/tmp/hooks.json5": "hooks-start-hash",
},
}),
});
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expectRestartNoticeLogged();
});
it("uses the mutation-start snapshot for updater input and hook selection", async () => {
const loadedConfig = {
hooks: {
internal: {
installs: {
"old-hooks": {
source: "npm",
spec: "@acme/old-hooks@1.0.0",
installPath: "/tmp/hooks/old-hooks",
},
},
},
},
plugins: {
entries: {
alpha: { enabled: true },
},
},
} as OpenClawConfig;
const snapshotConfig = {
hooks: {
internal: {
installs: {
"new-hooks": {
source: "npm",
spec: "@acme/new-hooks@1.0.0",
installPath: "~/.openclaw/hooks/new-hooks",
},
},
},
},
plugins: {
entries: {
alpha: { enabled: false },
},
},
} as OpenClawConfig;
const installRecords = {
alpha: {
source: "npm",
spec: "@openclaw/alpha@1.0.0",
installPath: "/tmp/alpha",
},
} as const;
primeUpdateConfigSnapshot({
config: snapshotConfig,
loadedConfig,
runtimeConfig: {
...snapshotConfig,
hooks: {
internal: {
installs: {
"new-hooks": {
source: "npm",
spec: "@acme/new-hooks@1.0.0",
installPath: "/home/test/.openclaw/hooks/new-hooks",
},
},
},
},
messages: {
ackReactionScope: "group-mentions",
},
},
});
setInstalledPluginIndexInstallRecords(installRecords);
updateNpmInstalledPlugins.mockImplementation(async (params: { config: OpenClawConfig }) => ({
config: params.config,
changed: false,
outcomes: [],
}));
updateNpmInstalledHookPacks.mockImplementation(async (params: { config: OpenClawConfig }) => ({
config: params.config,
changed: false,
outcomes: [],
}));
await runPluginsCommand(["plugins", "update", "--all"]);
const pluginUpdateParams = expectSingleCallParams(updateNpmInstalledPlugins);
const hookUpdateParams = expectSingleCallParams(updateNpmInstalledHookPacks);
expect(pluginUpdateParams.config).toEqual({
...snapshotConfig,
hooks: {
internal: {
installs: {
"new-hooks": {
source: "npm",
spec: "@acme/new-hooks@1.0.0",
installPath: "/home/test/.openclaw/hooks/new-hooks",
},
},
},
},
messages: {
ackReactionScope: "group-mentions",
},
plugins: {
...snapshotConfig.plugins,
installs: installRecords,
},
});
expect(hookUpdateParams.hookIds).toEqual(["new-hooks"]);
});
it("uses resolved shipped install records instead of raw env placeholders", async () => {
const cfg = createTrackedPluginConfig({
pluginId: "alpha",
spec: "@openclaw/alpha@1.0.0",
});
primeUpdateConfigSnapshot({
config: cfg,
parsed: {
plugins: {
installs: {
alpha: {
source: "npm",
spec: "${PLUGIN_SPEC}",
installPath: "${PLUGIN_PATH}",
},
},
},
},
});
updateNpmInstalledPlugins.mockResolvedValue({
config: cfg,
changed: false,
outcomes: [],
});
await runPluginsCommand(["plugins", "update", "alpha"]);
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
expect(updateParams.config).toEqual(cfg);
});
it("rejects invalid config snapshots before updater side effects", async () => {
const cfg = createTrackedPluginConfig({
pluginId: "alpha",
spec: "@openclaw/alpha@1.0.0",
});
primeUpdateConfigSnapshot({
config: cfg,
valid: false,
});
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
await expect(runPluginsCommand(["plugins", "update", "alpha"])).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toBe(
"Cannot update plugins or hooks while the config is invalid.",
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("blocks hook pack updates before updater side effects when hooks config is include-owned", async () => {
const cfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.0.0",
installPath: "/tmp/hooks/demo-hooks",
resolvedName: "@acme/demo-hooks",
},
},
},
},
} as OpenClawConfig;
primeBlockedUpdateConfig("hooks", cfg);
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain(
"Config hooks are stored in an external or unresolved top-level $include",
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("allows index-only legacy id migration when an included plugins section has no references", async () => {
const cfg = { plugins: {} } as OpenClawConfig;
const pluginRecords = createTrackedPluginConfig({
pluginId: "voice-call",
spec: "@openclaw/voice-call@1.0.0",
}).plugins?.installs;
const nextConfig = {
...cfg,
plugins: {
...cfg.plugins,
installs: {
"@openclaw/voice-call": {
source: "npm",
spec: "@openclaw/voice-call@1.1.0",
},
},
},
} as OpenClawConfig;
primeBlockedUpdateConfig("plugins", cfg);
setInstalledPluginIndexInstallRecords(pluginRecords ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
config: nextConfig,
changed: true,
outcomes: [
{
pluginId: "@openclaw/voice-call",
status: "updated",
message: "Updated @openclaw/voice-call.",
},
],
});
await runPluginsCommand(["plugins", "update", "--all"]);
expect(runtimeErrors).toEqual([]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expect(writeConfigFile).toHaveBeenCalledWith(cfg);
});
it("allows scoped non-npm updates beside include-owned plugin config", async () => {
const pluginId = "@acme/demo";
const cfg = {
plugins: {
entries: {
[pluginId]: { enabled: true },
},
},
} as OpenClawConfig;
const pluginRecords = {
[pluginId]: {
source: "git",
spec: "https://github.com/acme/demo.git#v1.0.0",
installPath: "/tmp/demo",
},
} as const;
const nextConfig = {
...cfg,
plugins: {
...cfg.plugins,
installs: pluginRecords,
},
} as OpenClawConfig;
primeBlockedUpdateConfig("plugins", cfg);
setInstalledPluginIndexInstallRecords(pluginRecords);
updateNpmInstalledPlugins.mockResolvedValue({
config: nextConfig,
changed: true,
outcomes: [{ pluginId, status: "updated", message: `Updated ${pluginId}.` }],
});
await runPluginsCommand(["plugins", "update", pluginId]);
expect(runtimeErrors).toEqual([]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(pluginRecords);
expect(writeConfigFile).toHaveBeenCalledWith(cfg);
});
it("blocks legacy plugin id migration before updater side effects", async () => {
const cfg = {
plugins: {
entries: {
"voice-call": { enabled: true },
},
},
} as OpenClawConfig;
primeBlockedUpdateConfig("plugins", cfg);
setInstalledPluginIndexInstallRecords({
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call",
installPath: "/tmp/voice-call",
},
});
await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors.at(-1)).toContain(
"Config plugins are stored in an external or unresolved top-level $include",
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it.each([
{
label: "ClawHub",
record: {
source: "clawhub",
spec: "clawhub:@openclaw/voice-call",
clawhubPackage: "@openclaw/voice-call",
installPath: "/tmp/voice-call",
},
},
{
label: "git",
record: {
source: "git",
spec: "https://github.com/openclaw/voice-call.git",
installPath: "/tmp/voice-call",
},
},
{
label: "marketplace",
record: {
source: "marketplace",
marketplaceSource: "acme",
marketplacePlugin: "voice-call",
installPath: "/tmp/voice-call",
},
},
] as const)(
"blocks possible $label id migration before updater side effects",
async ({ record }) => {
const cfg = {
plugins: {
entries: {
"voice-call": { enabled: true },
},
},
} as OpenClawConfig;
primeBlockedUpdateConfig("plugins", cfg);
setInstalledPluginIndexInstallRecords({
"voice-call": record,
});
await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors.at(-1)).toContain(
"Config plugins are stored in an external or unresolved top-level $include",
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
},
);
it("blocks possible legacy id migration when an included plugins section is unresolved", async () => {
const externalPath = path.join(
path.parse(process.cwd()).root,
"external-openclaw",
"plugins.json5",
);
const cfg = { plugins: {} } as OpenClawConfig;
primeUpdateConfigSnapshot({
config: cfg,
parsed: { plugins: { $include: externalPath } },
sourceConfig: { plugins: { $include: externalPath } } as unknown as OpenClawConfig,
includeFileTargetsForWrite: {
[externalPath]: externalPath,
},
});
setInstalledPluginIndexInstallRecords({
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call",
installPath: "/tmp/voice-call",
},
});
await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors.at(-1)).toContain(
"Config plugins are stored in an external or unresolved top-level $include",
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("preflights legacy plugin-record cleanup before hook-only updater side effects", async () => {
const cfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.0.0",
installPath: "/tmp/hooks/demo-hooks",
},
},
},
},
plugins: {
installs: {
legacy: {
source: "npm",
spec: "@openclaw/legacy@1.0.0",
installPath: "/tmp/legacy",
},
},
},
} as OpenClawConfig;
primeBlockedUpdateConfig("plugins", cfg);
await expect(runPluginsCommand(["plugins", "update", "demo-hooks"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors.at(-1)).toContain(
"Config plugins are stored in an external or unresolved top-level $include",
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("preserves skip behavior for plugin records whose source cannot be updated", async () => {
const cfg = {
plugins: {
installs: {
linked: {
source: "path",
sourcePath: "/tmp/linked",
installPath: "/tmp/linked",
},
},
},
} as OpenClawConfig;
primeBlockedUpdateConfig("plugins", cfg);
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
config: cfg,
changed: false,
outcomes: [{ pluginId: "linked", status: "skipped", message: "Skipping linked." }],
});
await runPluginsCommand(["plugins", "update", "--all"]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("preserves skip behavior for ClawHub records missing package metadata", async () => {
const cfg = {
plugins: {
entries: {
demo: { enabled: true },
},
},
} as OpenClawConfig;
primeBlockedUpdateConfig("plugins", cfg);
setInstalledPluginIndexInstallRecords({
demo: {
source: "clawhub",
spec: "clawhub:demo",
installPath: "/tmp/demo",
},
});
updateNpmInstalledPlugins.mockResolvedValue({
config: cfg,
changed: false,
outcomes: [
{
pluginId: "demo",
status: "skipped",
message: 'Skipping "demo" (missing ClawHub package metadata).',
},
],
});
await runPluginsCommand(["plugins", "update", "demo"]);
expect(runtimeErrors).toEqual([]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("preserves an include-owned plugins section during legacy-record cleanup", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-"));
const configPath = path.join(tempRoot, "openclaw.json5");
const pluginsPath = path.join(tempRoot, "plugins.json5");
const cfg = createTrackedPluginConfig({
pluginId: "alpha",
spec: "@openclaw/alpha@1.0.0",
});
const pluginsRaw = `${JSON.stringify(cfg.plugins, null, 2)}\n`;
const nextConfig = createTrackedPluginConfig({
pluginId: "alpha",
spec: "@openclaw/alpha@1.1.0",
});
fs.writeFileSync(pluginsPath, pluginsRaw);
primeUpdateConfigSnapshot({
config: cfg,
configPath,
parsed: { plugins: { $include: "./plugins.json5" } },
includeFileHashesForWrite: {
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
},
includeFileTargetsForWrite: {
[pluginsPath]: fs.realpathSync(pluginsPath),
},
});
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
config: nextConfig,
changed: true,
outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha." }],
});
try {
await runPluginsCommand(["plugins", "update", "alpha"]);
expect(runtimeErrors).toEqual([]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expect(writeConfigFile).toHaveBeenCalledWith({ plugins: {} });
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("migrates included legacy install records while updating another indexed plugin", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-"));
const configPath = path.join(tempRoot, "openclaw.json5");
const pluginsPath = path.join(tempRoot, "plugins.json5");
const legacyRecord = {
source: "npm",
spec: "@openclaw/legacy@1.0.0",
installPath: "/tmp/legacy",
} as const;
const indexedRecord = {
source: "npm",
spec: "@openclaw/alpha@1.0.0",
installPath: "/tmp/alpha",
} as const;
const updatedIndexedRecord = {
...indexedRecord,
spec: "@openclaw/alpha@1.1.0",
} as const;
const cfg = {
plugins: {
installs: {
legacy: legacyRecord,
},
},
} as OpenClawConfig;
const pluginsRaw = `${JSON.stringify(cfg.plugins, null, 2)}\n`;
const nextInstallRecords = {
alpha: updatedIndexedRecord,
legacy: legacyRecord,
};
fs.writeFileSync(pluginsPath, pluginsRaw);
primeUpdateConfigSnapshot({
config: cfg,
configPath,
parsed: { plugins: { $include: "./plugins.json5" } },
includeFileHashesForWrite: {
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
},
includeFileTargetsForWrite: {
[pluginsPath]: fs.realpathSync(pluginsPath),
},
});
setInstalledPluginIndexInstallRecords({
alpha: indexedRecord,
});
updateNpmInstalledPlugins.mockResolvedValue({
config: {
plugins: {
installs: nextInstallRecords,
},
} as OpenClawConfig,
changed: true,
outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha." }],
});
try {
await runPluginsCommand(["plugins", "update", "alpha"]);
expect(runtimeErrors).toEqual([]);
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
expect(updateParams.config).toEqual({
plugins: {
installs: {
alpha: indexedRecord,
legacy: legacyRecord,
},
},
});
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextInstallRecords,
);
expect(writeConfigFile).toHaveBeenCalledWith({ plugins: {} });
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("blocks combined plugin and hook updates when either config section uses an include", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-"));
const configPath = path.join(tempRoot, "openclaw.json5");
const pluginsPath = path.join(tempRoot, "plugins.json5");
const pluginsRaw = "{}\n";
fs.writeFileSync(pluginsPath, pluginsRaw);
const cfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.0.0",
installPath: "/tmp/hooks/demo-hooks",
},
},
},
},
plugins: {
installs: {
alpha: {
source: "npm",
spec: "@openclaw/alpha@1.0.0",
installPath: "/tmp/alpha",
},
},
},
} as OpenClawConfig;
primeUpdateConfigSnapshot({
config: cfg,
configPath,
parsed: {
hooks: {},
plugins: { $include: "./plugins.json5" },
},
includeFileHashesForWrite: {
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
},
includeFileTargetsForWrite: {
[pluginsPath]: fs.realpathSync(pluginsPath),
},
});
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
try {
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain(
"Config plugins and hooks cannot be updated together while either section uses a top-level $include",
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("exits when update is called without id and without --all", async () => {
loadConfig.mockReturnValue({
plugins: {
@@ -261,29 +1001,55 @@ describe("plugins cli update", () => {
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
const runtimeConfig = {
...cfg,
messages: {
ackReactionScope: "group-mentions",
},
} as OpenClawConfig;
const nextRuntimeConfig = {
...nextConfig,
messages: runtimeConfig.messages,
} as OpenClawConfig;
primeUpdateConfigSnapshot({
config: cfg,
runtimeConfig,
includeFileHashesForWrite: {
"/tmp/plugins.json5": "plugins-start-hash",
},
});
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }],
outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha -> 1.1.0" }],
changed: true,
config: nextConfig,
config: nextRuntimeConfig,
});
updateNpmInstalledHookPacks.mockResolvedValue({
outcomes: [],
changed: false,
config: nextConfig,
config: nextRuntimeConfig,
});
await runPluginsCommand(["plugins", "update", "alpha"]);
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
expect(updateParams.config).toEqual(cfg);
expect(updateParams.config).toEqual(runtimeConfig);
expect(updateParams.pluginIds).toEqual(["alpha"]);
expect(updateParams.dryRun).toBe(false);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).toHaveBeenCalledWith({});
expect(replaceConfigFile).toHaveBeenCalledWith({
nextConfig: {},
baseHash: "update-config",
writeOptions: expect.objectContaining({
includeFileHashesForWrite: {
"/tmp/plugins.json5": "plugins-start-hash",
},
}),
});
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: {},
installRecords: nextConfig.plugins?.installs,

View File

@@ -3,10 +3,16 @@ import fs from "node:fs";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js";
import {
assertConfigWriteAllowedInCurrentMode,
readConfigFileSnapshotForWrite,
} from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js";
import {
installHooksFromNpmSpec,
installHooksFromPath,
type InstallHooksResult,
} from "../hooks/install.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -58,8 +64,21 @@ import {
parseNpmPackPrefixPath,
parseNpmPrefixSpec,
} from "./plugins-command-helpers.js";
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js";
import {
persistHookPackInstall,
persistPluginInstall,
resolveInstallConfigMutationPreflights,
selectInstallMutationWriteOptions,
supportsInstallConfigSingleTopLevelIncludeShape,
type ConfigMutationPreflight,
type ConfigSnapshotForInstallPersist,
} from "./plugins-install-persist.js";
import { listPersistedBundledPluginRecoveryLocations } from "./plugins-location-bridges.js";
type ConfigSnapshotForInstallExecution = ConfigSnapshotForInstallPersist & {
hookMutation: ConfigMutationPreflight;
pluginMutation: ConfigMutationPreflight;
};
function resolveInstallMode(force?: boolean): "install" | "update" {
return force ? "update" : "install";
@@ -73,6 +92,26 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta
};
}
async function probeHookPackFromNpmSpec(
params: Parameters<typeof installHooksFromNpmSpec>[0],
): Promise<InstallHooksResult> {
try {
return await installHooksFromNpmSpec(params);
} catch (error) {
return { ok: false, error: formatErrorMessage(error) };
}
}
async function probeHookPackFromPath(
params: Parameters<typeof installHooksFromPath>[0],
): Promise<InstallHooksResult> {
try {
return await installHooksFromPath(params);
} catch (error) {
return { ok: false, error: formatErrorMessage(error) };
}
}
const DEPRECATED_DANGEROUS_FORCE_UNSAFE_INSTALL_WARNING =
"--dangerously-force-unsafe-install is deprecated and no longer affects plugin installs because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions.";
@@ -106,6 +145,31 @@ function isEmptyRecord(value: Record<string, unknown>): boolean {
return Object.keys(value).length === 0;
}
function supportsPluginRecoveryIncludeShape(parsed: Record<string, unknown>): boolean {
if (Object.hasOwn(parsed, "$include")) {
return false;
}
return supportsInstallConfigSingleTopLevelIncludeShape(parsed.plugins);
}
function resolveFullyBlockedConfigMutationReason(
snapshot: ConfigSnapshotForInstallExecution,
): string | null {
if (snapshot.pluginMutation.mode !== "blocked" || snapshot.hookMutation.mode !== "blocked") {
return null;
}
if (snapshot.pluginMutation.reason === snapshot.hookMutation.reason) {
return snapshot.pluginMutation.reason;
}
return `Config plugin and hook mutations are both blocked. ${snapshot.pluginMutation.reason} ${snapshot.hookMutation.reason}`;
}
function assertPluginConfigMutationAllowed(preflight: ConfigMutationPreflight): void {
if (preflight.mode === "blocked") {
throw buildInvalidPluginInstallConfigError(preflight.reason);
}
}
function hasValidBundledPluginConfig(params: {
bundledSource: BundledPluginSource;
existingEntry: unknown;
@@ -147,7 +211,7 @@ function prepareConfigForDisabledBundledInstall(
}
async function installBundledPluginSource(params: {
snapshot: ConfigSnapshotForInstallPersist;
snapshot: ConfigSnapshotForInstallExecution;
rawSpec: string;
bundledSource: BundledPluginSource;
warning: string;
@@ -168,8 +232,8 @@ async function installBundledPluginSource(params: {
: `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`;
await persistPluginInstall({
snapshot: {
...params.snapshot,
config: configBase,
baseHash: params.snapshot.baseHash,
},
pluginId: params.bundledSource.pluginId,
install: {
@@ -186,13 +250,17 @@ async function installBundledPluginSource(params: {
}
async function tryInstallHookPackFromLocalPath(params: {
snapshot: ConfigSnapshotForInstallPersist;
snapshot: ConfigSnapshotForInstallExecution;
resolvedPath: string;
installMode: "install" | "update";
safetyOverrides?: InstallSafetyOverrides;
link?: boolean;
expectedPackageKind?: "hook-only";
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (params.snapshot.hookMutation.mode === "blocked") {
return { ok: false, error: params.snapshot.hookMutation.reason };
}
if (params.link) {
const stat = fs.statSync(params.resolvedPath);
if (!stat.isDirectory()) {
@@ -206,6 +274,7 @@ async function tryInstallHookPackFromLocalPath(params: {
...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}),
path: params.resolvedPath,
dryRun: true,
...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}),
});
if (!probe.ok) {
return probe;
@@ -215,6 +284,7 @@ async function tryInstallHookPackFromLocalPath(params: {
const merged = uniqueStrings([...existing, params.resolvedPath]);
await persistHookPackInstall({
snapshot: {
...params.snapshot,
config: {
...params.snapshot.config,
hooks: {
@@ -229,7 +299,6 @@ async function tryInstallHookPackFromLocalPath(params: {
},
},
},
baseHash: params.snapshot.baseHash,
},
hookPackId: probe.hookPackId,
hooks: probe.hooks,
@@ -249,6 +318,7 @@ async function tryInstallHookPackFromLocalPath(params: {
...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}),
path: params.resolvedPath,
mode: params.installMode,
...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}),
logger: createHookPackInstallLogger(params.runtime),
});
if (!result.ok) {
@@ -272,17 +342,23 @@ async function tryInstallHookPackFromLocalPath(params: {
}
async function tryInstallHookPackFromNpmSpec(params: {
snapshot: ConfigSnapshotForInstallPersist;
snapshot: ConfigSnapshotForInstallExecution;
installMode: "install" | "update";
spec: string;
pin?: boolean;
expectedIntegrity?: string;
expectedPackageKind?: "hook-only";
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (params.snapshot.hookMutation.mode === "blocked") {
return { ok: false, error: params.snapshot.hookMutation.reason };
}
const result = await installHooksFromNpmSpec({
config: params.snapshot.config,
spec: params.spec,
mode: params.installMode,
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}),
logger: createHookPackInstallLogger(params.runtime),
});
if (!result.ok) {
@@ -309,7 +385,7 @@ async function tryInstallHookPackFromNpmSpec(params: {
}
async function tryInstallPluginOrHookPackFromNpmSpec(params: {
snapshot: ConfigSnapshotForInstallPersist;
snapshot: ConfigSnapshotForInstallExecution;
installMode: "install" | "update";
spec: string;
pin?: boolean;
@@ -322,6 +398,49 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
invalidateRuntimeCache?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(params.snapshot);
if (fullyBlockedReason) {
(params.runtime ?? defaultRuntime).error(fullyBlockedReason);
return { ok: false };
}
if (
params.snapshot.pluginMutation.mode === "blocked" ||
params.snapshot.hookMutation.mode === "blocked"
) {
const hookProbe = await probeHookPackFromNpmSpec({
config: params.snapshot.config,
spec: params.spec,
mode: params.installMode,
inspection: "package-kind",
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
logger: createHookPackInstallLogger(params.runtime),
});
if (hookProbe.ok && hookProbe.packageKind === "hook-only") {
if (params.snapshot.hookMutation.mode === "blocked") {
(params.runtime ?? defaultRuntime).error(params.snapshot.hookMutation.reason);
return { ok: false };
}
const hookFallback = await tryInstallHookPackFromNpmSpec({
snapshot: params.snapshot,
installMode: params.installMode,
spec: params.spec,
pin: params.pin,
expectedIntegrity: hookProbe.npmResolution?.integrity ?? params.expectedIntegrity,
expectedPackageKind: "hook-only",
runtime: params.runtime,
});
if (hookFallback.ok) {
return { ok: true };
}
(params.runtime ?? defaultRuntime).error(hookFallback.error);
return { ok: false };
}
if (params.snapshot.pluginMutation.mode === "blocked") {
(params.runtime ?? defaultRuntime).error(params.snapshot.pluginMutation.reason);
return { ok: false };
}
}
const result = await installPluginFromNpmSpec({
...params.safetyOverrides,
mode: params.installMode,
@@ -394,7 +513,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
}
async function tryInstallPluginFromNpmPackArchive(params: {
snapshot: ConfigSnapshotForInstallPersist;
snapshot: ConfigSnapshotForInstallExecution;
installMode: "install" | "update";
archivePath: string;
safetyOverrides: InstallSafetyOverrides;
@@ -444,7 +563,7 @@ async function tryInstallPluginFromNpmPackArchive(params: {
}
async function tryInstallPluginFromGitSpec(params: {
snapshot: ConfigSnapshotForInstallPersist;
snapshot: ConfigSnapshotForInstallExecution;
installMode: "install" | "update";
spec: string;
safetyOverrides: InstallSafetyOverrides;
@@ -494,8 +613,7 @@ function isTerminalPluginInstallFailure(code?: string): boolean {
function isAllowedPluginRecoveryIssue(
issue: { path?: string; message?: string },
request: PluginInstallRequestContext,
installRecords: Record<string, PluginInstallRecord>,
env: NodeJS.ProcessEnv = process.env,
ownedLoadPaths: ReadonlySet<string>,
): boolean {
const pluginId = request.bundledPluginId?.trim();
if (!pluginId) {
@@ -504,9 +622,7 @@ function isAllowedPluginRecoveryIssue(
return (
(issue.path === `channels.${pluginId}` &&
issue.message === `unknown channel id: ${pluginId}`) ||
(issue.path === "plugins.load.paths" &&
typeof issue.message === "string" &&
isMissingPluginLoadPathForInstallRecord({ issue, installRecords, pluginId, env })) ||
isOwnedMissingPluginLoadPathIssue(issue, ownedLoadPaths) ||
(issue.path === `plugins.entries.${pluginId}` &&
typeof issue.message === "string" &&
issue.message.includes("requires compiled runtime output")) ||
@@ -522,21 +638,6 @@ function buildInvalidPluginInstallConfigError(message: string): Error {
return error;
}
function hasConfigInclude(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some((child) => hasConfigInclude(child));
}
if (!isRecord(value)) {
return false;
}
if (Object.hasOwn(value, "$include")) {
return true;
}
return Object.values(value).some((child) => hasConfigInclude(child));
}
const ENV_VAR_REFERENCE_RE = /\$\{[A-Z_][A-Z0-9_]*\}/;
function extractMissingPluginLoadPath(issue: { path?: string; message?: string }): string | null {
if (issue.path !== "plugins.load.paths" || typeof issue.message !== "string") {
return null;
@@ -550,116 +651,68 @@ function extractMissingPluginLoadPath(issue: { path?: string; message?: string }
return value || null;
}
function resolvePluginInstallRecordPaths(params: {
installRecords: Record<string, PluginInstallRecord>;
pluginId: string;
env: NodeJS.ProcessEnv;
}): Set<string> {
const install = params.installRecords[params.pluginId];
function collectRequestedPluginInstallPaths(
cfg: OpenClawConfig,
installRecords: Awaited<ReturnType<typeof loadInstalledPluginIndexInstallRecords>>,
request: PluginInstallRequestContext,
env: NodeJS.ProcessEnv = process.env,
): Set<string> {
const pluginId = request.bundledPluginId?.trim();
if (!pluginId) {
return new Set();
}
const paths = new Set<string>();
for (const value of [install?.installPath, install?.sourcePath]) {
const record = installRecords[pluginId] ?? cfg.plugins?.installs?.[pluginId];
for (const value of [record?.sourcePath, record?.installPath]) {
if (typeof value === "string" && value.trim()) {
paths.add(resolveUserPath(value, params.env));
paths.add(resolveUserPath(value, env));
}
}
return paths;
}
function isMissingPluginLoadPathForInstallRecord(params: {
issue: { path?: string; message?: string };
installRecords: Record<string, PluginInstallRecord>;
pluginId: string;
env: NodeJS.ProcessEnv;
}): boolean {
const missingPath = extractMissingPluginLoadPath(params.issue);
if (!missingPath) {
return false;
}
return resolvePluginInstallRecordPaths(params).has(resolveUserPath(missingPath, params.env));
function isOwnedMissingPluginLoadPathIssue(
issue: { path?: string; message?: string },
ownedLoadPaths: ReadonlySet<string>,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const missingPath = extractMissingPluginLoadPath(issue);
return missingPath !== null && ownedLoadPaths.has(resolveUserPath(missingPath, env));
}
function readPluginLoadPathEntries(cfg: unknown): unknown[] | undefined {
if (!isRecord(cfg) || !isRecord(cfg.plugins) || !isRecord(cfg.plugins.load)) {
return undefined;
async function collectRequestedPluginLocationBridgePaths(
request: PluginInstallRequestContext,
env: NodeJS.ProcessEnv,
): Promise<Set<string>> {
const pluginId = request.bundledPluginId?.trim();
if (!pluginId) {
return new Set();
}
const paths = cfg.plugins.load.paths;
return Array.isArray(paths) ? paths : undefined;
}
function arrayHasEnvRef(value: unknown): boolean {
return (
Array.isArray(value) &&
value.some((entry) => typeof entry === "string" && ENV_VAR_REFERENCE_RE.test(entry))
const locations = await listPersistedBundledPluginRecoveryLocations({ env });
return new Set(
locations
.filter((location) => location.pluginId === pluginId)
.flatMap((location) => location.loadPaths.map((loadPath) => resolveUserPath(loadPath, env))),
);
}
function hasAuthoredPluginPolicyEnvRefs(params: {
authoredConfig: unknown;
resolvedConfig: OpenClawConfig;
pluginId: string;
}): boolean {
if (!isRecord(params.authoredConfig) || !isRecord(params.authoredConfig.plugins)) {
return false;
}
const resolvedPlugins = params.resolvedConfig.plugins;
const allowWillChange =
Array.isArray(resolvedPlugins?.allow) &&
resolvedPlugins.allow.length > 0 &&
!resolvedPlugins.allow.includes(params.pluginId);
if (allowWillChange && arrayHasEnvRef(params.authoredConfig.plugins.allow)) {
return true;
}
const denyWillChange =
Array.isArray(resolvedPlugins?.deny) && resolvedPlugins.deny.includes(params.pluginId);
return denyWillChange && arrayHasEnvRef(params.authoredConfig.plugins.deny);
}
function wouldMoveAuthoredEnvPluginLoadPath(params: {
cfg: OpenClawConfig;
issues: readonly { path?: string; message?: string }[];
authoredConfig: unknown;
env: NodeJS.ProcessEnv;
}): boolean {
const missingPaths = new Set(
params.issues
.map(extractMissingPluginLoadPath)
.filter((value): value is string => Boolean(value))
.map((value) => resolveUserPath(value, params.env)),
);
const paths = params.cfg.plugins?.load?.paths;
const authoredPaths = readPluginLoadPathEntries(params.authoredConfig);
if (missingPaths.size === 0 || !Array.isArray(paths) || !Array.isArray(authoredPaths)) {
return false;
}
let removedBefore = false;
for (const [index, entry] of paths.entries()) {
if (typeof entry === "string" && missingPaths.has(resolveUserPath(entry, params.env))) {
removedBefore = true;
continue;
}
const authoredEntry = authoredPaths[index];
if (
removedBefore &&
typeof authoredEntry === "string" &&
ENV_VAR_REFERENCE_RE.test(authoredEntry)
) {
return true;
}
}
return false;
}
function removeMissingPluginLoadPaths(
function removeOwnedMissingPluginLoadPaths(
cfg: OpenClawConfig,
issues: readonly { path?: string; message?: string }[],
ownedLoadPaths: ReadonlySet<string>,
env: NodeJS.ProcessEnv = process.env,
): OpenClawConfig {
const missingPaths = new Set(
issues
.map(extractMissingPluginLoadPath)
.filter((value): value is string => Boolean(value))
.map((value) => resolveUserPath(value, env)),
);
const missingPaths = new Set<string>();
for (const issue of issues) {
const missingPath = extractMissingPluginLoadPath(issue);
if (!missingPath) {
continue;
}
const resolved = resolveUserPath(missingPath, env);
if (ownedLoadPaths.has(resolved)) {
missingPaths.add(resolved);
}
}
const paths = cfg.plugins?.load?.paths;
if (missingPaths.size === 0 || !Array.isArray(paths)) {
return cfg;
@@ -682,10 +735,38 @@ function removeMissingPluginLoadPaths(
};
}
async function resolveRequestedPluginInstallPaths(
cfg: OpenClawConfig,
issues: readonly { path?: string; message?: string }[],
request: PluginInstallRequestContext,
env: NodeJS.ProcessEnv = process.env,
): Promise<Set<string>> {
if (!issues.some((issue) => extractMissingPluginLoadPath(issue) !== null)) {
return new Set();
}
const installRecords = await loadInstalledPluginIndexInstallRecords();
const ownedLoadPaths = collectRequestedPluginInstallPaths(cfg, installRecords, request, env);
const stillNeedsLocationBridge = issues.some(
(issue) =>
extractMissingPluginLoadPath(issue) !== null &&
!isOwnedMissingPluginLoadPathIssue(issue, ownedLoadPaths, env),
);
if (stillNeedsLocationBridge) {
// The persisted bundled registry proves this plugin previously owned its
// removed core path; do not infer ownership from the requested id alone.
for (const loadPath of await collectRequestedPluginLocationBridgePaths(request, env)) {
ownedLoadPaths.add(loadPath);
}
}
return ownedLoadPaths;
}
async function loadConfigFromSnapshotForInstall(
request: PluginInstallRequestContext,
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
): Promise<ConfigSnapshotForInstallPersist> {
prepared: Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>>,
): Promise<ConfigSnapshotForInstallExecution> {
const { snapshot, writeOptions } = prepared;
const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions);
if (resolvePluginInstallInvalidConfigPolicy(request) !== "allow-plugin-recovery") {
throw buildInvalidPluginInstallConfigError(
"Config invalid; run `openclaw doctor --fix` before installing plugins.",
@@ -697,77 +778,77 @@ async function loadConfigFromSnapshotForInstall(
"Config file could not be parsed; run `openclaw doctor` to repair it.",
);
}
const pluginId = request.bundledPluginId?.trim() ?? "";
const pluginLabel = pluginId || "the requested plugin";
if (hasConfigInclude(snapshot.parsed)) {
throw buildInvalidPluginInstallConfigError(
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
);
}
if (
hasAuthoredPluginPolicyEnvRefs({
authoredConfig: snapshot.parsed,
resolvedConfig: snapshot.config,
pluginId,
})
) {
throw buildInvalidPluginInstallConfigError(
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
);
}
const persistedInstallRecords = await tracePluginLifecyclePhaseAsync(
"install records load",
() => loadInstalledPluginIndexInstallRecords(),
{ command: "install" },
const ownedLoadPaths = await resolveRequestedPluginInstallPaths(
snapshot.config,
snapshot.issues,
request,
process.env,
);
const installRecords = {
...snapshot.config.plugins?.installs,
...persistedInstallRecords,
};
if (
snapshot.legacyIssues.length > 0 ||
snapshot.issues.length === 0 ||
snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request, installRecords))
snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request, ownedLoadPaths))
) {
const pluginLabel = request.bundledPluginId ?? "the requested plugin";
throw buildInvalidPluginInstallConfigError(
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
);
}
let nextConfig = snapshot.config;
if (
wouldMoveAuthoredEnvPluginLoadPath({
cfg: nextConfig,
issues: snapshot.issues,
authoredConfig: snapshot.parsed,
env: process.env,
})
) {
if (!supportsPluginRecoveryIncludeShape(parsed)) {
throw buildInvalidPluginInstallConfigError(
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
"Config plugin recovery uses an unsupported $include shape; use a single-file top-level plugins include or run `openclaw doctor --fix` before reinstalling it.",
);
}
nextConfig = removeMissingPluginLoadPaths(nextConfig, snapshot.issues, process.env);
const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({
parsed,
snapshotPath: snapshot.path,
writeOptions: mutationWriteOptions,
});
assertPluginConfigMutationAllowed(pluginMutation);
const nextConfig = removeOwnedMissingPluginLoadPaths(
snapshot.config,
snapshot.issues,
ownedLoadPaths,
process.env,
);
return {
config: nextConfig,
baseHash: snapshot.hash,
writeOptions: mutationWriteOptions,
hookMutation,
pluginMutation,
};
}
export async function loadConfigForInstall(
request: PluginInstallRequestContext,
): Promise<ConfigSnapshotForInstallPersist> {
const snapshot = await tracePluginLifecyclePhaseAsync(
): Promise<ConfigSnapshotForInstallExecution> {
const prepared = await tracePluginLifecyclePhaseAsync(
"config read",
() => readConfigFileSnapshot(),
() => readConfigFileSnapshotForWrite(),
{ command: "install" },
);
const { snapshot, writeOptions } = prepared;
const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions);
if (snapshot.valid) {
const parsed = (snapshot.parsed ?? {}) as Record<string, unknown>;
const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({
parsed,
snapshotPath: snapshot.path,
writeOptions: mutationWriteOptions,
});
if (request.installKind === "plugin") {
assertPluginConfigMutationAllowed(pluginMutation);
}
return {
config: snapshot.sourceConfig,
baseHash: snapshot.hash,
writeOptions: mutationWriteOptions,
hookMutation,
pluginMutation,
};
}
return loadConfigFromSnapshotForInstall(request, snapshot);
return loadConfigFromSnapshotForInstall(request, prepared);
}
export async function runPluginInstallCommand(params: {
@@ -846,6 +927,8 @@ export async function runPluginInstallCommand(params: {
);
return runtime.exit(1);
}
const npmPackPath = parseNpmPackPrefixPath(raw);
const clawhubSpec = parseClawHubPluginSpec(raw);
const requestResolution = resolvePluginInstallRequestContext({
rawSpec: raw,
marketplace: opts.marketplace,
@@ -854,7 +937,41 @@ export async function runPluginInstallCommand(params: {
runtime.error(requestResolution.error);
return runtime.exit(1);
}
const request = requestResolution.request;
let request = requestResolution.request;
const resolved = request.resolvedPath ?? request.normalizedSpec;
const resolvesToLocalPath = fs.existsSync(resolved);
if (!resolvesToLocalPath && (gitSpec || npmPackPath !== null || clawhubSpec)) {
request = { ...request, installKind: "plugin" };
}
const bundledPreNpmPlan = resolvesToLocalPath
? null
: resolveBundledInstallPlanBeforeNpm({
rawSpec: raw,
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
const officialExternalPlan = resolvesToLocalPath
? null
: resolveOfficialExternalInstallPlanBeforeNpm({
rawSpec: raw,
findOfficialExternalPlugin: (pluginId) => {
const entry = getOfficialExternalPluginCatalogEntry(pluginId);
const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined;
const install = entry ? resolveOfficialExternalPluginInstall(entry) : null;
const npmSpec = install?.npmSpec;
return resolvedPluginId && npmSpec
? {
pluginId: resolvedPluginId,
npmSpec,
...(install.expectedIntegrity
? { expectedIntegrity: install.expectedIntegrity }
: {}),
}
: undefined;
},
});
if (bundledPreNpmPlan || officialExternalPlan) {
request = { ...request, installKind: "plugin" };
}
const snapshot = await loadConfigForInstall(request).catch((error: unknown) => {
runtime.error(formatErrorMessage(error));
return null;
@@ -898,8 +1015,44 @@ export async function runPluginInstallCommand(params: {
return;
}
const resolved = request.resolvedPath ?? request.normalizedSpec;
if (fs.existsSync(resolved)) {
const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(snapshot);
if (fullyBlockedReason) {
runtime.error(fullyBlockedReason);
return runtime.exit(1);
}
if (snapshot.pluginMutation.mode === "blocked" || snapshot.hookMutation.mode === "blocked") {
const hookProbe = await probeHookPackFromPath({
...safetyOverrides,
path: resolved,
mode: installMode,
inspection: "package-kind",
});
if (hookProbe.ok && hookProbe.packageKind === "hook-only") {
if (snapshot.hookMutation.mode === "blocked") {
runtime.error(snapshot.hookMutation.reason);
return runtime.exit(1);
}
const hookFallback = await tryInstallHookPackFromLocalPath({
snapshot,
installMode,
resolvedPath: resolved,
safetyOverrides,
...(opts.link ? { link: true } : {}),
expectedPackageKind: "hook-only",
runtime,
});
if (hookFallback.ok) {
return;
}
runtime.error(hookFallback.error);
return runtime.exit(1);
}
if (snapshot.pluginMutation.mode === "blocked") {
runtime.error(snapshot.pluginMutation.reason);
return runtime.exit(1);
}
}
if (opts.link) {
const existing = cfg.plugins?.load?.paths ?? [];
const merged = uniqueStrings([...existing, resolved]);
@@ -934,6 +1087,7 @@ export async function runPluginInstallCommand(params: {
await persistPluginInstall({
snapshot: {
...snapshot,
config: {
...cfg,
plugins: {
@@ -944,7 +1098,6 @@ export async function runPluginInstallCommand(params: {
},
},
},
baseHash: snapshot.baseHash,
},
pluginId: probe.pluginId,
install: {
@@ -1047,7 +1200,6 @@ export async function runPluginInstallCommand(params: {
return;
}
const npmPackPath = parseNpmPackPrefixPath(raw);
if (npmPackPath !== null) {
if (!npmPackPath) {
runtime.error(
@@ -1104,10 +1256,6 @@ export async function runPluginInstallCommand(params: {
return runtime.exit(1);
}
const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({
rawSpec: raw,
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
if (bundledPreNpmPlan) {
await tracePluginLifecyclePhaseAsync(
"install execution",
@@ -1129,22 +1277,6 @@ export async function runPluginInstallCommand(params: {
return;
}
const officialExternalPlan = resolveOfficialExternalInstallPlanBeforeNpm({
rawSpec: raw,
findOfficialExternalPlugin: (pluginId) => {
const entry = getOfficialExternalPluginCatalogEntry(pluginId);
const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined;
const install = entry ? resolveOfficialExternalPluginInstall(entry) : null;
const npmSpec = install?.npmSpec;
return resolvedPluginId && npmSpec
? {
pluginId: resolvedPluginId,
npmSpec,
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
}
: undefined;
},
});
if (officialExternalPlan) {
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
@@ -1166,7 +1298,6 @@ export async function runPluginInstallCommand(params: {
return;
}
const clawhubSpec = parseClawHubPluginSpec(raw);
if (clawhubSpec) {
const result = await installPluginFromClawHub({
...safetyOverrides,

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,12 @@ function expectRuntimeLogIncludes(fragment: string) {
expect(runtimeLogs.join("\n")).toContain(fragment);
}
const installWriteOptions = {
assertConfigPathForWrite: () => {},
expectedConfigPath: "/tmp/openclaw.json",
ownedConfigPathForWrite: "/tmp/openclaw.json",
};
describe("persistPluginInstall", () => {
beforeEach(() => {
resetPluginsCliTestState();
@@ -49,7 +55,7 @@ describe("persistPluginInstall", () => {
} as OpenClawConfig;
const enabledConfig = {
plugins: {
allow: ["alpha", "memory-core"],
allow: ["memory-core", "alpha"],
entries: {
alpha: { enabled: true },
},
@@ -58,7 +64,7 @@ describe("persistPluginInstall", () => {
enablePluginInConfig.mockImplementation((...args: unknown[]) => {
const [cfg, pluginId] = args as [OpenClawConfig, string];
expect(pluginId).toBe("alpha");
expect(cfg.plugins?.allow).toEqual(["alpha", "memory-core"]);
expect(cfg.plugins?.allow).toEqual(["memory-core", "alpha"]);
return { config: enabledConfig };
});
@@ -66,6 +72,13 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: {
assertConfigPathForWrite: installWriteOptions.assertConfigPathForWrite,
expectedConfigPath: "/tmp/openclaw.json",
ownedConfigPathForWrite: "/tmp/openclaw.json",
includeFileHashesForWrite: { "/tmp/plugins.json5": "include-1" },
includeFileTargetsForWrite: { "/tmp/plugins.json5": "/tmp/plugins.json5" },
},
},
pluginId: "alpha",
install: {
@@ -91,6 +104,11 @@ describe("persistPluginInstall", () => {
nextConfig: enabledConfig,
baseHash: "config-1",
writeOptions: {
assertConfigPathForWrite: installWriteOptions.assertConfigPathForWrite,
expectedConfigPath: "/tmp/openclaw.json",
ownedConfigPathForWrite: "/tmp/openclaw.json",
includeFileHashesForWrite: { "/tmp/plugins.json5": "include-1" },
includeFileTargetsForWrite: { "/tmp/plugins.json5": "/tmp/plugins.json5" },
afterWrite: { mode: "restart", reason: "plugin source changed" },
unsetPaths: [["plugins", "installs"]],
},
@@ -130,6 +148,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "alpha",
install: {
@@ -194,6 +213,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "codex",
install: {
@@ -257,6 +277,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "codex",
install: {
@@ -301,6 +322,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "discord",
install: {
@@ -359,6 +381,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "discord",
install: {
@@ -392,6 +415,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "alpha",
install: {
@@ -427,6 +451,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "alpha",
install: {
@@ -468,6 +493,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "alpha",
install: {
@@ -535,6 +561,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "legacy-memory",
install: {
@@ -607,6 +634,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "memory-b",
install: {
@@ -657,6 +685,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "plain",
install: {
@@ -689,6 +718,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "memory-lancedb",
enable: false,
@@ -730,6 +760,7 @@ describe("persistPluginInstall", () => {
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "memory-lancedb",
enable: false,

View File

@@ -1,6 +1,15 @@
// Persistence helpers for plugin and hook-pack installs plus related config mutation.
import fs from "node:fs";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { replaceConfigFile } from "../config/config.js";
import {
hashConfigIncludeRaw,
readConfigIncludeFileWithGuards,
resolveConfigIncludeWritePath,
} from "../config/includes.js";
import type { ConfigWriteOptions } from "../config/io.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js";
@@ -21,6 +30,7 @@ import {
} from "../plugins/uninstall.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import {
applySlotSelectionForPlugin,
enableInternalHookEntries,
@@ -39,7 +49,9 @@ function addInstalledPluginToAllowlist(cfg: OpenClawConfig, pluginId: string): O
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId].toSorted(),
// Preserve authored allowlist order so env-backed entries remain aligned
// with the write-time env restoration snapshot.
allow: [...allow, pluginId],
},
};
}
@@ -66,8 +78,236 @@ function removeInstalledPluginFromDenylist(cfg: OpenClawConfig, pluginId: string
export type ConfigSnapshotForInstallPersist = {
config: OpenClawConfig;
baseHash: string | undefined;
writeOptions: Pick<
ConfigWriteOptions,
| "assertConfigPathForWrite"
| "expectedConfigPath"
| "ownedConfigPathForWrite"
| "envSnapshotForRestore"
| "includeFileHashesForWrite"
| "includeFileTargetsForWrite"
>;
};
type ConfigMutationSection = "hooks" | "plugins";
export type ConfigMutationPreflight =
| { mode: "allowed" }
| { mode: "blocked"; scope: "config" | ConfigMutationSection; reason: string };
const CONFIG_MUTATION_ALLOWED = { mode: "allowed" } as const;
export function containsConfigIncludeDirective(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some((entry) => containsConfigIncludeDirective(entry));
}
if (!isRecord(value)) {
return false;
}
return (
Object.hasOwn(value, "$include") ||
Object.values(value).some((entry) => containsConfigIncludeDirective(entry))
);
}
export function supportsInstallConfigSingleTopLevelIncludeShape(authoredSection: unknown): boolean {
if (!containsConfigIncludeDirective(authoredSection)) {
return true;
}
return (
isRecord(authoredSection) &&
Object.keys(authoredSection).length === 1 &&
typeof authoredSection.$include === "string"
);
}
function resolveSingleTopLevelIncludePath(
parsed: Record<string, unknown>,
configPath: string,
section: ConfigMutationSection,
): string | null {
const authoredSection = parsed[section];
if (
!isRecord(authoredSection) ||
Object.keys(authoredSection).length !== 1 ||
typeof authoredSection.$include !== "string"
) {
return null;
}
return path.normalize(
path.isAbsolute(authoredSection.$include)
? authoredSection.$include
: path.resolve(path.dirname(configPath), authoredSection.$include),
);
}
function resolveConfigMutationPreflight(params: {
parsed: Record<string, unknown>;
section: ConfigMutationSection;
snapshotPath: string;
writeOptions: ConfigSnapshotForInstallPersist["writeOptions"];
}): ConfigMutationPreflight {
if (Object.hasOwn(params.parsed, "$include")) {
return {
mode: "blocked",
scope: "config",
reason: `Config ${params.section} are stored through an unsupported $include shape at the root; edit the included file directly or move ${params.section} into the root config before installing.`,
};
}
if (!supportsInstallConfigSingleTopLevelIncludeShape(params.parsed[params.section])) {
return {
mode: "blocked",
scope: params.section,
reason: `Config ${params.section} are stored through an unsupported $include shape; edit the included file directly or move ${params.section} to a single-file top-level include before installing.`,
};
}
const includePath = resolveSingleTopLevelIncludePath(
params.parsed,
params.snapshotPath,
params.section,
);
if (!includePath) {
return CONFIG_MUTATION_ALLOWED;
}
const expectedTarget = params.writeOptions.includeFileTargetsForWrite?.[includePath];
let resolvedTarget: string | null = null;
try {
resolvedTarget = resolveConfigIncludeWritePath({
configPath: params.snapshotPath,
includePath,
allowedRoots: [],
});
} catch {
// The persistence path rejects includes that are no longer root-bound too.
}
if (
expectedTarget &&
resolvedTarget &&
path.normalize(expectedTarget) === path.normalize(resolvedTarget)
) {
const expectedHash = params.writeOptions.includeFileHashesForWrite?.[includePath];
try {
const raw = readConfigIncludeFileWithGuards({
includePath,
resolvedPath: resolvedTarget,
rootRealDir: fs.realpathSync(path.dirname(params.snapshotPath)),
});
if (expectedHash !== hashConfigIncludeRaw(raw)) {
return {
mode: "blocked",
scope: params.section,
reason: `Config ${params.section} include changed since the config was read; rerun the install after reloading the config.`,
};
}
if (containsConfigIncludeDirective(parseJsonWithJson5Fallback(raw))) {
return {
mode: "blocked",
scope: params.section,
reason: `Config ${params.section} are stored through a nested $include; edit the included file directly or remove the nested $include before installing.`,
};
}
return CONFIG_MUTATION_ALLOWED;
} catch {
return {
mode: "blocked",
scope: params.section,
reason: `Config ${params.section} include could not be inspected at its snapshot target; rerun the install after repairing or reloading the config.`,
};
}
}
return {
mode: "blocked",
scope: params.section,
reason: `Config ${params.section} are stored in an external or unresolved top-level $include; edit the included file directly or move it under the config directory before installing.`,
};
}
export function resolveInstallConfigMutationPreflights(params: {
parsed: Record<string, unknown>;
snapshotPath: string;
writeOptions: ConfigSnapshotForInstallPersist["writeOptions"];
}): {
hookMutation: ConfigMutationPreflight;
pluginMutation: ConfigMutationPreflight;
} {
const pluginMutation = resolveConfigMutationPreflight({
...params,
section: "plugins",
});
const hookMutation = resolveConfigMutationPreflight({
...params,
section: "hooks",
});
const pluginIncludePath = resolveSingleTopLevelIncludePath(
params.parsed,
params.snapshotPath,
"plugins",
);
const hookIncludePath = resolveSingleTopLevelIncludePath(
params.parsed,
params.snapshotPath,
"hooks",
);
const pluginTarget = pluginIncludePath
? params.writeOptions.includeFileTargetsForWrite?.[pluginIncludePath]
: undefined;
const hookTarget = hookIncludePath
? params.writeOptions.includeFileTargetsForWrite?.[hookIncludePath]
: undefined;
if (pluginTarget && hookTarget && path.normalize(pluginTarget) === path.normalize(hookTarget)) {
const blocked = {
mode: "blocked",
scope: "config",
reason:
"Config plugins and hooks share the same top-level $include target; split them into separate include files before installing.",
} as const;
return { hookMutation: blocked, pluginMutation: blocked };
}
return { hookMutation, pluginMutation };
}
export function resolveCombinedPluginAndHookConfigMutationPreflight(params: {
parsed: Record<string, unknown>;
snapshotPath: string;
}): ConfigMutationPreflight {
const pluginIncludePath = resolveSingleTopLevelIncludePath(
params.parsed,
params.snapshotPath,
"plugins",
);
const hookIncludePath = resolveSingleTopLevelIncludePath(
params.parsed,
params.snapshotPath,
"hooks",
);
if (!pluginIncludePath && !hookIncludePath) {
return CONFIG_MUTATION_ALLOWED;
}
return {
mode: "blocked",
scope: "config",
reason:
"Config plugins and hooks cannot be updated together while either section uses a top-level $include; update them separately.",
};
}
export function selectInstallMutationWriteOptions(
writeOptions: ConfigWriteOptions,
): ConfigSnapshotForInstallPersist["writeOptions"] {
// Install work may outlive its config read. Keep only mutation-start ownership
// and conflict facts; plugin metadata must come from the commit-time read.
return {
...(writeOptions.assertConfigPathForWrite
? { assertConfigPathForWrite: writeOptions.assertConfigPathForWrite }
: {}),
expectedConfigPath: writeOptions.expectedConfigPath,
ownedConfigPathForWrite: writeOptions.ownedConfigPathForWrite,
envSnapshotForRestore: writeOptions.envSnapshotForRestore,
includeFileHashesForWrite: writeOptions.includeFileHashesForWrite,
includeFileTargetsForWrite: writeOptions.includeFileTargetsForWrite,
};
}
function sourceMatchesInstalledPath(params: {
activeSource: string;
installedSource: string;
@@ -237,6 +477,7 @@ export async function persistPluginInstall(params: {
nextConfig: next,
baseHash: params.snapshot.baseHash,
writeOptions: {
...params.snapshot.writeOptions,
afterWrite: { mode: "restart", reason: "plugin source changed" },
},
}),
@@ -302,6 +543,7 @@ export async function persistHookPackInstall(params: {
await replaceConfigFile({
nextConfig: next,
baseHash: params.snapshot.baseHash,
writeOptions: params.snapshot.writeOptions,
});
runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`);
logHookPackRestartHint(runtime);

View File

@@ -24,7 +24,8 @@ vi.mock("../plugins/manifest-registry-installed.js", () => ({
loadPluginManifestRegistryForInstalledIndexMock(...args),
}));
const { listPersistedBundledPluginLocationBridges } = await import("./plugins-location-bridges.js");
const { listPersistedBundledPluginLocationBridges, listPersistedBundledPluginRecoveryLocations } =
await import("./plugins-location-bridges.js");
function makeIndex(record: InstalledPluginIndex["plugins"][number]): InstalledPluginIndex {
return {
@@ -185,3 +186,50 @@ describe("listPersistedBundledPluginLocationBridges", () => {
await expect(listPersistedBundledPluginLocationBridges({})).resolves.toStrictEqual([]);
});
});
describe("listPersistedBundledPluginRecoveryLocations", () => {
beforeEach(() => {
readPersistedInstalledPluginIndexMock.mockReset();
loadPluginManifestRegistryForInstalledIndexMock.mockReset();
});
it("includes exact packaged and legacy paths for disabled bundled records", async () => {
readPersistedInstalledPluginIndexMock.mockResolvedValue(
makeIndex({
pluginId: "diagnostics-otel",
manifestPath: "/app/dist/extensions/diagnostics-otel/openclaw.plugin.json",
manifestHash: "hash",
source: "/app/dist/extensions/diagnostics-otel/index.js",
rootDir: "/app/dist/extensions/diagnostics-otel",
origin: "bundled",
enabled: false,
startup: startupInfo,
compat: [],
}),
);
await expect(listPersistedBundledPluginRecoveryLocations({})).resolves.toEqual([
{
pluginId: "diagnostics-otel",
loadPaths: ["/app/dist/extensions/diagnostics-otel", "/app/extensions/diagnostics-otel"],
},
]);
});
it("does not use a relative persisted bundled root as ownership proof", async () => {
readPersistedInstalledPluginIndexMock.mockResolvedValue(
makeIndex({
pluginId: "diagnostics-otel",
manifestPath: "extensions/diagnostics-otel/openclaw.plugin.json",
manifestHash: "hash",
source: "extensions/diagnostics-otel/index.js",
rootDir: "extensions/diagnostics-otel",
origin: "bundled",
enabled: true,
startup: startupInfo,
compat: [],
}),
);
await expect(listPersistedBundledPluginRecoveryLocations({})).resolves.toStrictEqual([]);
});
});

View File

@@ -1,4 +1,6 @@
// Bridge builder for users upgrading from bundled plugins to external plugin packages.
import path from "node:path";
import { buildBundledPluginLoadPathAliases } from "../plugins/bundled-load-path-aliases.js";
import type { ExternalizedBundledPluginBridge } from "../plugins/externalized-bundled-plugins.js";
import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js";
@@ -10,6 +12,11 @@ import {
resolveOfficialExternalPluginInstall,
} from "../plugins/official-external-plugin-catalog.js";
export type PersistedBundledPluginRecoveryLocation = {
pluginId: string;
loadPaths: readonly string[];
};
function buildBridgeFromPersistedBundledRecord(
record: InstalledPluginIndexRecord,
manifest?: PluginManifestRecord,
@@ -76,3 +83,23 @@ export async function listPersistedBundledPluginLocationBridges(options: {
return bridge ? [bridge] : [];
});
}
/** List exact previous bundled paths that an explicit plugin reinstall may recover. */
export async function listPersistedBundledPluginRecoveryLocations(options: {
env?: NodeJS.ProcessEnv;
}): Promise<readonly PersistedBundledPluginRecoveryLocation[]> {
const index = await readPersistedInstalledPluginIndex(options);
if (!index) {
return [];
}
return index.plugins.flatMap((record) => {
const rootDir = record.rootDir.trim();
if (record.origin !== "bundled" || !path.isAbsolute(rootDir)) {
return [];
}
const loadPaths = Array.from(
new Set([rootDir, ...buildBundledPluginLoadPathAliases(rootDir).map((alias) => alias.path)]),
);
return [{ pluginId: record.pluginId, loadPaths }];
});
}

View File

@@ -3,17 +3,32 @@ import { theme } from "../../packages/terminal-core/src/theme.js";
import {
assertConfigWriteAllowedInCurrentMode,
getRuntimeConfig,
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
replaceConfigFile,
} from "../config/config.js";
import { createMergePatch } from "../config/io.write-prepare.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { extractShippedPluginInstallConfigRecords } from "../config/plugin-install-config-migration.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { updateNpmInstalledHookPacks } from "../hooks/update.js";
import {
loadInstalledPluginIndexInstallRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../plugins/installed-plugin-index-records.js";
import { updateNpmInstalledPlugins } from "../plugins/update.js";
import {
isPluginInstallRecordUpdateSource,
pluginInstallRecordMayMigrateConfigId,
updateNpmInstalledPlugins,
} from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
import {
containsConfigIncludeDirective,
resolveCombinedPluginAndHookConfigMutationPreflight,
resolveInstallConfigMutationPreflights,
selectInstallMutationWriteOptions,
} from "./plugins-install-persist.js";
import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js";
import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js";
@@ -26,6 +41,62 @@ import { promptYesNo } from "./prompt.js";
const DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING =
"--dangerously-force-unsafe-install is deprecated and no longer affects plugin updates because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions.";
function mayMutatePluginInstallRecord(
record: PluginInstallRecord | undefined,
specOverride: string | undefined,
): boolean {
if (!isPluginInstallRecordUpdateSource(record)) {
return false;
}
if (record?.source === "npm") {
return Boolean(specOverride ?? record.spec);
}
if (record?.source === "git") {
return Boolean(record.spec);
}
if (record?.source === "clawhub") {
return Boolean(record.clawhubPackage);
}
return Boolean(record?.marketplaceSource && record.marketplacePlugin);
}
function pluginConfigReferencesId(config: ReturnType<typeof getRuntimeConfig>, pluginId: string) {
const plugins = config.plugins;
return (
plugins?.allow?.includes(pluginId) ||
plugins?.deny?.includes(pluginId) ||
Object.hasOwn(plugins?.entries ?? {}, pluginId) ||
plugins?.slots?.memory === pluginId ||
plugins?.slots?.contextEngine === pluginId
);
}
function shouldPreserveEmptyPlugins(params: {
parsed: unknown;
sourceConfig: ReturnType<typeof getRuntimeConfig>;
}): boolean {
const plugins = params.sourceConfig.plugins;
const parsedPlugins =
params.parsed && typeof params.parsed === "object" && !Array.isArray(params.parsed)
? (params.parsed as Record<string, unknown>).plugins
: undefined;
return Boolean(
plugins &&
(!Object.hasOwn(plugins, "installs") ||
Object.keys(plugins).some((key) => key !== "installs") ||
containsConfigIncludeDirective(parsedPlugins)),
);
}
function projectUpdaterResultOntoSourceConfig(params: {
runtimeBase: OpenClawConfig;
sourceBase: OpenClawConfig;
updatedConfig: OpenClawConfig;
}): OpenClawConfig {
const updatePatch = createMergePatch(params.runtimeBase, params.updatedConfig);
return applyMergePatch(params.sourceBase, updatePatch) as OpenClawConfig;
}
/** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */
export async function runPluginUpdateCommand(params: {
id?: string;
@@ -33,10 +104,42 @@ export async function runPluginUpdateCommand(params: {
}) {
assertConfigWriteAllowedInCurrentMode();
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
const cfg = getRuntimeConfig();
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const sourceSnapshotPromise = readConfigFileSnapshotForWrite()
.then((prepared) => ({
...prepared,
writeOptions: selectInstallMutationWriteOptions(prepared.writeOptions),
}))
.catch(() => null);
const mutationSnapshot = params.opts.dryRun ? null : await sourceSnapshotPromise;
if (!params.opts.dryRun && !mutationSnapshot) {
defaultRuntime.error("Could not inspect config ownership before updating plugins or hooks.");
return defaultRuntime.exit(1);
}
if (mutationSnapshot && !mutationSnapshot.snapshot.valid) {
defaultRuntime.error("Cannot update plugins or hooks while the config is invalid.");
return defaultRuntime.exit(1);
}
// Bind selection, updater input, ownership checks, and persistence to one
// mutation-start snapshot so concurrent config changes cannot be resurrected.
const cfg = mutationSnapshot?.snapshot.runtimeConfig ?? getRuntimeConfig();
const sourceCfg = mutationSnapshot?.snapshot.sourceConfig ?? cfg;
const shippedPluginInstallRecords = mutationSnapshot
? {
...extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.parsed),
...extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.sourceConfig),
}
: extractShippedPluginInstallConfigRecords(cfg);
const persistedPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
// Persisted index records win over shipped legacy config during migration.
const pluginInstallRecords = {
...shippedPluginInstallRecords,
...persistedPluginInstallRecords,
};
const cfgWithPluginInstallRecords = withPluginInstallRecords(cfg, pluginInstallRecords);
const sourceCfgWithPluginInstallRecords = withPluginInstallRecords(
sourceCfg,
pluginInstallRecords,
);
const logger = {
info: (msg: string) => defaultRuntime.log(msg),
warn: (msg: string) => defaultRuntime.log(theme.warn(msg)),
@@ -64,49 +167,143 @@ export async function runPluginUpdateCommand(params: {
return defaultRuntime.exit(1);
}
const pluginResult = await updateNpmInstalledPlugins({
config: cfgWithPluginInstallRecords,
pluginIds: pluginSelection.pluginIds,
specOverrides: pluginSelection.specOverrides,
dryRun: params.opts.dryRun,
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
const selectedHooks = cfg.hooks?.internal?.installs ?? {};
const pluginUpdateMayMutate =
!params.opts.dryRun &&
pluginSelection.pluginIds.some((pluginId) => {
return mayMutatePluginInstallRecord(
pluginInstallRecords[pluginId],
pluginSelection.specOverrides?.[pluginId],
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
},
});
const hookResult = await updateNpmInstalledHookPacks({
config: pluginResult.config,
hookIds: hookSelection.hookIds,
specOverrides: hookSelection.specOverrides,
dryRun: params.opts.dryRun,
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
});
const hookUpdateMayMutate =
!params.opts.dryRun &&
hookSelection.hookIds.some((hookId) => {
const record = selectedHooks[hookId];
return (
record?.source === "npm" && Boolean(hookSelection.specOverrides?.[hookId] ?? record.spec)
);
if (drift.dryRun) {
return true;
});
if (pluginUpdateMayMutate || hookUpdateMayMutate) {
if (!mutationSnapshot) {
defaultRuntime.error("Could not inspect config ownership before updating plugins or hooks.");
return defaultRuntime.exit(1);
}
const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({
parsed: (mutationSnapshot.snapshot.parsed ?? {}) as Record<string, unknown>,
snapshotPath: mutationSnapshot.snapshot.path,
writeOptions: mutationSnapshot.writeOptions,
});
// Write snapshots retain valid shipped install records in sourceConfig after
// include resolution; parsed also catches root-authored legacy records.
const pluginRecordCleanupMayMutate =
Object.keys(extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.sourceConfig))
.length > 0 ||
Object.keys(extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.parsed))
.length > 0;
const parsedConfig =
mutationSnapshot.snapshot.parsed &&
typeof mutationSnapshot.snapshot.parsed === "object" &&
!Array.isArray(mutationSnapshot.snapshot.parsed)
? (mutationSnapshot.snapshot.parsed as Record<string, unknown>)
: {};
const pluginReferencesMayBeUnresolved =
Object.hasOwn(parsedConfig, "$include") ||
containsConfigIncludeDirective(mutationSnapshot.snapshot.sourceConfig.plugins);
const pluginIdMigrationMayMutate = pluginSelection.pluginIds.some((pluginId) => {
return (
pluginInstallRecordMayMigrateConfigId({
pluginId,
record: pluginInstallRecords[pluginId],
specOverride: pluginSelection.specOverrides?.[pluginId],
}) &&
(pluginReferencesMayBeUnresolved ||
pluginConfigReferencesId(mutationSnapshot.snapshot.sourceConfig, pluginId))
);
});
// Manual update records stay in the index unless shipped-record cleanup or
// scoped-package compatibility migrates authored references from a legacy id.
const pluginConfigMayMutate = pluginRecordCleanupMayMutate || pluginIdMigrationMayMutate;
const blockedReasons = new Set<string>();
if (pluginConfigMayMutate && pluginMutation.mode === "blocked") {
blockedReasons.add(pluginMutation.reason);
}
if (hookUpdateMayMutate && hookMutation.mode === "blocked") {
blockedReasons.add(hookMutation.reason);
}
if (
pluginConfigMayMutate &&
hookUpdateMayMutate &&
pluginMutation.mode === "allowed" &&
hookMutation.mode === "allowed"
) {
// Config persistence can commit one include-owned top-level section, not
// a mixed plugin-and-hook mutation spanning root and include ownership.
const combinedMutation = resolveCombinedPluginAndHookConfigMutationPreflight({
parsed: (mutationSnapshot.snapshot.parsed ?? {}) as Record<string, unknown>,
snapshotPath: mutationSnapshot.snapshot.path,
});
if (combinedMutation.mode === "blocked") {
blockedReasons.add(combinedMutation.reason);
}
return await promptYesNo(`Continue updating hook pack "${drift.hookId}" with this artifact?`);
},
});
}
if (blockedReasons.size > 0) {
defaultRuntime.error(Array.from(blockedReasons).join(" "));
return defaultRuntime.exit(1);
}
}
const pluginResult =
pluginSelection.pluginIds.length > 0
? await updateNpmInstalledPlugins({
config: cfgWithPluginInstallRecords,
pluginIds: pluginSelection.pluginIds,
specOverrides: pluginSelection.specOverrides,
dryRun: params.opts.dryRun,
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
},
})
: { config: cfgWithPluginInstallRecords, changed: false, outcomes: [] };
const hookResult =
hookSelection.hookIds.length > 0
? await updateNpmInstalledHookPacks({
config: pluginResult.config,
hookIds: hookSelection.hookIds,
specOverrides: hookSelection.specOverrides,
dryRun: params.opts.dryRun,
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(
`Continue updating hook pack "${drift.hookId}" with this artifact?`,
);
},
})
: { config: pluginResult.config, changed: false, outcomes: [] };
const outcomeSummary = logPluginUpdateOutcomes({
outcomes: [...pluginResult.outcomes, ...hookResult.outcomes],
@@ -114,27 +311,39 @@ export async function runPluginUpdateCommand(params: {
});
if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) {
const sourceSnapshot = mutationSnapshot ?? (await sourceSnapshotPromise);
const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {};
const shouldPersistPluginInstallIndex =
pluginResult.changed || Object.keys(pluginInstallRecords).length > 0;
// Plugin install records live in the persisted index; config only carries hook-pack changes.
const nextConfig = shouldPersistPluginInstallIndex
? withoutPluginInstallRecords(hookResult.config)
: hookResult.config;
const sourceShapedUpdateConfig = projectUpdaterResultOntoSourceConfig({
runtimeBase: cfgWithPluginInstallRecords,
sourceBase: sourceCfgWithPluginInstallRecords,
updatedConfig: hookResult.config,
});
// Plugin install records live in the persisted index. Preserve an authored
// empty plugins section so include ownership does not become a false mutation.
const nextConfig = withoutPluginInstallRecords(sourceShapedUpdateConfig, {
preserveEmptyPlugins: shouldPreserveEmptyPlugins({
parsed: sourceSnapshot?.snapshot.parsed,
sourceConfig: sourceSnapshot?.snapshot.sourceConfig ?? {},
}),
});
if (shouldPersistPluginInstallIndex) {
await commitPluginInstallRecordsWithConfig({
previousInstallRecords: pluginInstallRecords,
previousInstallRecords: persistedPluginInstallRecords,
nextInstallRecords: nextPluginInstallRecords,
nextConfig,
baseHash: (await sourceSnapshotPromise)?.hash,
baseHash: sourceSnapshot?.snapshot.hash,
writeOptions: {
...sourceSnapshot?.writeOptions,
afterWrite: { mode: "restart", reason: "plugin source changed" },
},
});
} else {
await replaceConfigFile({
nextConfig,
baseHash: (await sourceSnapshotPromise)?.hash,
baseHash: sourceSnapshot?.snapshot.hash,
writeOptions: sourceSnapshot?.writeOptions,
});
}
if (pluginResult.changed) {