Files
openclaw/src/plugins/uninstall.ts
T
Peter Steinberger da4a656cdb improve: doctor migration checks no longer load every bundled plugin runtime (#120678)
* perf(plugins): declare doctor contract surfaces

* perf(doctor): slim migration import closures

* perf(plugins): narrow doctor declaration record surface and wire owner-test lane

Registry records carry only the doctorContract declaration instead of the whole
parsed manifest, and check:changed now selects the src/plugins-owned declaration
honesty and closure-guard tests for extension module/manifest changes so
cross-lane drift cannot pass PR classification.

* fix(doctor): keep control-plane dist imports require-safe

Keep doctor and channel control-plane chunks off exec-class dependencies, and enforce native require(esm) loading during postbuild.

* chore(plugin-sdk): regenerate API baseline

* chore(plugin-sdk): sync export ordering

* fix(plugins): satisfy doctor contract CI boundaries

* perf(doctor): make qqbot doctor closure dependency-light

qqbot was the last plugin above 5s in doctor state-migration enumeration
(~8s under tsx/jiti). The cost was not the state-key builder (already a
leaf): its doctor closure value-imported the runtime-doctor SDK barrel,
whose plugin-state-store/state-db re-exports pull kysely (~330 modules),
plus security-runtime for one fileExists (~200 modules), all resolved
per-module by jiti during enumeration.

Split the migration-define helpers and light re-exports into a new
private-local plugin-sdk/runtime-doctor-migrations subpath; runtime-doctor
re-exports it so its public surface is byte-identical (API baseline hash
unchanged). qqbot's doctor-contract and state-migrations now import only
the light subpath, swapping fileExists for the equivalent async
legacyStateFileExists already in the closure.

qqbot enumeration: ~8.0s/531 modules -> ~0.25s/18 modules.

* chore(plugin-sdk): drop private-local subpath from API baseline

runtime-doctor-migrations is private-local-only; the baseline tracks public
modules, and the earlier line was generated before the classification.

* fix(plugins): register runtime-doctor-migrations boundary paths

The private-local subpath list feeds the extension package boundary map;
the shared paths config and xai's derived overrides must carry the same
entry or the boundary contract test fails.
2026-08-08 13:29:18 -07:00

532 lines
16 KiB
TypeScript

// Removes installed plugins and updates plugin index records.
import { lstatSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { formatErrorMessage } from "../infra/errors.js";
import { readOpenClawManagedNpmRootOverrides } from "../infra/npm-managed-root.js";
import { createSafeNpmInstallEnv } from "../infra/safe-package-install.js";
import { runCommandWithTimeout } from "../process/exec.js";
import {
resolveDefaultPluginGitDir,
resolveDefaultPluginNpmDir,
resolvePluginInstallDir,
resolvePluginNpmProjectsDir,
} from "./install-paths.js";
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-link.js";
import { defaultSlotIdForKey } from "./slots.js";
import {
isUninstallPathInsideOrEqual,
removePluginFromConfig,
resolveComparableUninstallPath,
type PluginConfigUninstallActions,
} from "./uninstall-config.js";
import { pruneManagedNpmPeerDependenciesAfterUninstall } from "./uninstall-managed-npm.js";
export { resolveUninstallChannelConfigKeys } from "./uninstall-config.js";
type UninstallActions = PluginConfigUninstallActions & {
directory: boolean;
};
export const UNINSTALL_ACTION_LABELS = {
entry: "config entry",
install: "install record",
allowlist: "allowlist entry",
denylist: "denylist entry",
loadPath: "load path",
memorySlot: "memory slot",
contextEngineSlot: "context engine slot",
channelConfig: "channel config",
directory: "directory",
} satisfies Record<keyof UninstallActions, string>;
const UNINSTALL_ACTION_ORDER = [
"entry",
"install",
"allowlist",
"denylist",
"loadPath",
"memorySlot",
"contextEngineSlot",
"channelConfig",
"directory",
] as const satisfies ReadonlyArray<keyof UninstallActions>;
export function formatUninstallActionLabels(actions: UninstallActions): string[] {
return UNINSTALL_ACTION_ORDER.flatMap((key) =>
actions[key] ? [UNINSTALL_ACTION_LABELS[key]] : [],
);
}
/** Keep a staged plugin disabled until its managed directory is removed. */
export function prepareConfigForPendingPluginDirectoryRemoval(
config: OpenClawConfig,
pluginId: string,
): OpenClawConfig {
return {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[pluginId]: {
...config.plugins?.entries?.[pluginId],
enabled: false,
},
},
},
};
}
function hasUninstallAction(actions: PluginConfigUninstallActions): boolean {
return Object.values(actions).some(Boolean);
}
export function formatUninstallSlotResetPreview(slotKey: "memory" | "contextEngine"): string {
const actionKey = slotKey === "memory" ? "memorySlot" : "contextEngineSlot";
return `${UNINSTALL_ACTION_LABELS[actionKey]} (will reset to "${defaultSlotIdForKey(slotKey)}")`;
}
export type PluginUninstallDirectoryRemoval = {
target: string;
cleanup?:
| {
kind: "npm";
npmRoot: string;
packageName: string;
}
| {
kind: "git";
parentDir: string;
};
};
type PluginUninstallPlanResult =
| {
ok: true;
config: OpenClawConfig;
pluginId: string;
actions: UninstallActions;
directoryRemoval: PluginUninstallDirectoryRemoval | null;
}
| { ok: false; error: string };
function resolveUninstallDirectoryTarget(params: {
pluginId: string;
hasInstall: boolean;
installRecord?: PluginInstallRecord;
extensionsDir?: string;
}): string | null {
if (!params.hasInstall) {
return null;
}
if (isLinkedPathInstallRecord(params.installRecord)) {
return null;
}
const npmManagedInstall = resolveNpmManagedInstall({
installRecord: params.installRecord,
extensionsDir: params.extensionsDir,
});
if (npmManagedInstall) {
return npmManagedInstall.installPath;
}
const gitManagedInstall = resolveGitManagedInstall({
installRecord: params.installRecord,
extensionsDir: params.extensionsDir,
});
if (gitManagedInstall) {
return gitManagedInstall.installPath;
}
let defaultPath: string;
try {
defaultPath = resolvePluginInstallDir(params.pluginId, params.extensionsDir);
} catch {
return null;
}
const configuredPath = params.installRecord?.installPath;
if (!configuredPath) {
return defaultPath;
}
if (path.resolve(configuredPath) === path.resolve(defaultPath)) {
return configuredPath;
}
if (params.extensionsDir && isUninstallPathInsideOrEqual(params.extensionsDir, configuredPath)) {
return configuredPath;
}
const recordedManagedPath = resolveRecordedManagedInstallPath({
pluginId: params.pluginId,
installPath: configuredPath,
});
if (recordedManagedPath) {
return recordedManagedPath;
}
// Never trust configured installPath blindly for recursive deletes outside
// the managed extensions directory.
return defaultPath;
}
function resolveNpmManagedInstall(params: {
installRecord?: PluginInstallRecord;
extensionsDir?: string;
}): { installPath: string; npmRoot: string; packageName: string } | null {
const installPath = params.installRecord?.installPath?.trim();
if (params.installRecord?.source !== "npm" || !installPath) {
return null;
}
const npmRoots = new Set<string>();
if (params.extensionsDir) {
npmRoots.add(path.join(path.dirname(path.resolve(params.extensionsDir)), "npm"));
}
npmRoots.add(resolveDefaultPluginNpmDir());
for (const npmRoot of npmRoots) {
const nodeModulesRoot = path.join(npmRoot, "node_modules");
if (
isUninstallPathInsideOrEqual(nodeModulesRoot, installPath) &&
resolveComparableUninstallPath(nodeModulesRoot) !==
resolveComparableUninstallPath(installPath)
) {
const packageName = resolveNpmPackageNameFromInstallPath({ installPath, nodeModulesRoot });
return packageName ? { installPath, npmRoot, packageName } : null;
}
const projectMatch = resolveNpmManagedProjectInstall({
installPath,
projectsDir: resolvePluginNpmProjectsDir(npmRoot),
});
if (projectMatch) {
return projectMatch;
}
}
return null;
}
function resolveNpmManagedProjectInstall(params: {
installPath: string;
projectsDir: string;
}): { installPath: string; npmRoot: string; packageName: string } | null {
if (
!isUninstallPathInsideOrEqual(params.projectsDir, params.installPath) ||
resolveComparableUninstallPath(params.projectsDir) ===
resolveComparableUninstallPath(params.installPath)
) {
return null;
}
const relativePath = path.relative(
path.resolve(params.projectsDir),
path.resolve(params.installPath),
);
const segments = relativePath.split(path.sep).filter(Boolean);
if (segments.length < 3 || segments[1] !== "node_modules") {
return null;
}
const npmRoot = path.join(params.projectsDir, segments[0] ?? "");
const nodeModulesRoot = path.join(npmRoot, "node_modules");
const packageName = resolveNpmPackageNameFromInstallPath({
installPath: params.installPath,
nodeModulesRoot,
});
return packageName ? { installPath: params.installPath, npmRoot, packageName } : null;
}
function resolveNpmPackageNameFromInstallPath(params: {
installPath: string;
nodeModulesRoot: string;
}): string | null {
const relativePath = path.relative(
path.resolve(params.nodeModulesRoot),
path.resolve(params.installPath),
);
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return null;
}
const segments = relativePath.split(path.sep).filter(Boolean);
if (segments.length < 1) {
return null;
}
if (segments[0]?.startsWith("@")) {
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null;
}
return segments[0] ?? null;
}
function resolveGitManagedInstall(params: {
installRecord?: PluginInstallRecord;
extensionsDir?: string;
}): { installPath: string; parentDir: string } | null {
const installPath = params.installRecord?.installPath?.trim();
if (params.installRecord?.source !== "git" || !installPath) {
return null;
}
const gitRoots = new Set<string>();
if (params.extensionsDir) {
gitRoots.add(path.join(path.dirname(path.resolve(params.extensionsDir)), "git"));
}
gitRoots.add(resolveDefaultPluginGitDir());
for (const gitRoot of gitRoots) {
if (
isUninstallPathInsideOrEqual(gitRoot, installPath) &&
resolveComparableUninstallPath(gitRoot) !== resolveComparableUninstallPath(installPath)
) {
return { installPath, parentDir: path.dirname(installPath) };
}
}
return null;
}
function resolveRecordedManagedInstallPath(params: {
pluginId: string;
installPath: string;
}): string | null {
const resolvedInstallPath = path.resolve(params.installPath);
const recordedExtensionsDir = path.dirname(resolvedInstallPath);
if (path.basename(recordedExtensionsDir) !== "extensions") {
return null;
}
try {
const canonicalInstallPath = path.resolve(
resolvePluginInstallDir(params.pluginId, recordedExtensionsDir),
);
return canonicalInstallPath === resolvedInstallPath ? params.installPath : null;
} catch {
return null;
}
}
function isLinkedPathInstallRecord(installRecord: PluginInstallRecord | undefined): boolean {
if (installRecord?.source !== "path") {
return false;
}
if (!installRecord.sourcePath || !installRecord.installPath) {
return true;
}
return (
resolveComparableUninstallPath(installRecord.sourcePath) ===
resolveComparableUninstallPath(installRecord.installPath)
);
}
type UninstallPluginParams = {
config: OpenClawConfig;
pluginId: string;
channelIds?: string[];
deleteFiles?: boolean;
extensionsDir?: string;
};
/**
* Plan a plugin uninstall by removing it from config and resolving a safe file-removal target.
* Linked path plugins never have their source directory deleted. Copied path installs still remove
* their managed install directory.
*/
export function planPluginUninstall(params: UninstallPluginParams): PluginUninstallPlanResult {
const { config, pluginId, channelIds, deleteFiles = true, extensionsDir } = params;
const entries = config.plugins?.entries ?? {};
const installs = config.plugins?.installs ?? {};
const hasEntry = Object.hasOwn(entries, pluginId);
const hasInstall = Object.hasOwn(installs, pluginId);
const installRecord = hasInstall ? installs[pluginId] : undefined;
const isLinked = isLinkedPathInstallRecord(installRecord);
// Remove from config
const { config: newConfig, actions: configActions } = removePluginFromConfig(config, pluginId, {
channelIds,
});
if (!hasEntry && !hasInstall && !hasUninstallAction(configActions)) {
return { ok: false, error: `Plugin not found: ${pluginId}` };
}
const actions: UninstallActions = {
...configActions,
directory: false,
};
const npmManagedInstall =
deleteFiles && !isLinked
? resolveNpmManagedInstall({
installRecord,
extensionsDir,
})
: null;
const gitManagedInstall =
deleteFiles && !isLinked
? resolveGitManagedInstall({
installRecord,
extensionsDir,
})
: null;
const deleteTarget =
deleteFiles && !isLinked
? resolveUninstallDirectoryTarget({
pluginId,
hasInstall,
installRecord,
extensionsDir,
})
: null;
return {
ok: true,
config: newConfig,
pluginId,
actions,
directoryRemoval: deleteTarget
? {
target: deleteTarget,
...(npmManagedInstall
? {
cleanup: {
kind: "npm",
npmRoot: npmManagedInstall.npmRoot,
packageName: npmManagedInstall.packageName,
},
}
: gitManagedInstall && deleteTarget === gitManagedInstall.installPath
? {
cleanup: {
kind: "git",
parentDir: gitManagedInstall.parentDir,
},
}
: {}),
}
: null,
};
}
export function pluginUninstallTargetExists(target: string): boolean {
try {
lstatSync(target);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
export async function applyPluginUninstallDirectoryRemoval(
removal: PluginUninstallDirectoryRemoval | null,
): Promise<{ directoryRemoved: boolean; warnings: string[] }> {
if (!removal) {
return { directoryRemoved: false, warnings: [] };
}
const existed = pluginUninstallTargetExists(removal.target);
const warnings: string[] = [];
if (!existed && removal.cleanup?.kind !== "npm") {
return { directoryRemoved: false, warnings };
}
const npmCleanupManifestExists =
removal.cleanup?.kind === "npm"
? await fs
.access(path.join(removal.cleanup.npmRoot, "package.json"))
.then(() => true)
.catch(() => false)
: false;
if (!existed && removal.cleanup?.kind === "npm" && !npmCleanupManifestExists) {
return { directoryRemoved: false, warnings };
}
if (removal.cleanup?.kind === "npm" && npmCleanupManifestExists) {
const uninstall = await runCommandWithTimeout(
[
"npm",
"uninstall",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
removal.cleanup.packageName,
],
{
cwd: removal.cleanup.npmRoot,
timeoutMs: 300_000,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
npmConfigCwd: removal.cleanup.npmRoot,
packageLock: true,
quiet: true,
}),
},
);
if (uninstall.code !== 0) {
warnings.push(
`Failed to prune npm dependencies for plugin package ${removal.cleanup.packageName}: ${
uninstall.stderr.trim() ||
uninstall.stdout.trim() ||
`npm exited with code ${uninstall.code}`
}`,
);
}
try {
const managedOverrides = await readOpenClawManagedNpmRootOverrides();
const warning = await pruneManagedNpmPeerDependenciesAfterUninstall({
npmRoot: removal.cleanup.npmRoot,
packageName: removal.cleanup.packageName,
managedOverrides,
});
if (warning) {
warnings.push(warning);
}
} catch (error) {
warnings.push(
`Failed to sync managed peer dependencies after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`,
);
}
try {
await relinkOpenClawPeerDependenciesInManagedNpmRoot({
npmRoot: removal.cleanup.npmRoot,
logger: {
warn: (message) => warnings.push(message),
},
});
} catch (error) {
warnings.push(
`Failed to repair managed npm peer links after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`,
);
}
}
try {
await fs.rm(removal.target, { recursive: true, force: true });
if (removal.cleanup?.kind === "git") {
try {
await fs.rmdir(removal.cleanup.parentDir);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "ENOTEMPTY") {
warnings.push(
`Failed to remove empty git plugin install parent ${removal.cleanup.parentDir}: ${formatErrorMessage(error)}`,
);
}
}
}
return { directoryRemoved: existed, warnings };
} catch (error) {
return {
directoryRemoved: false,
warnings: [
...warnings,
`Failed to remove plugin directory ${removal.target}: ${formatErrorMessage(error)}`,
],
};
}
}