mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
da4a656cdb
* 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.
532 lines
16 KiB
TypeScript
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)}`,
|
|
],
|
|
};
|
|
}
|
|
}
|