mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 00:56:07 +00:00
fix(cli): harden official plugin recovery (#93325)
* fix(cli): harden official plugin recovery * fix(config): preserve include write context * fix(config): reject external include mutations * fix(config): bind snapshots to config paths * fix(config): preserve write ownership * fix(cli): preflight plugin config mutations * chore(plugin-sdk): refresh api baseline * test(config): prove install env policy mutations * fix(cli): preflight plugin updates * fix(cli): preflight non-npm id migrations * chore(plugin-sdk): refresh api baseline * fix(cli): satisfy plugin recovery checks
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user