mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 17:30:42 +00:00
Keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of persisting them into openclaw.json.
Refuse config writers, mutating update/plugin lifecycle commands, and doctor repair/token generation in Nix mode with agent-first nix-openclaw guidance.
Verification:
- pnpm check
- pnpm build
- pnpm test -- src/config/io.write-config.test.ts src/config/mutate.test.ts src/config/io.owner-display-secret.test.ts src/gateway/server-startup-config.recovery.test.ts src/gateway/startup-auth.test.ts src/gateway/startup-control-ui-origins.test.ts src/cli/plugins-cli.install.test.ts src/cli/plugins-cli.policy.test.ts src/cli/plugins-cli.uninstall.test.ts src/cli/plugins-cli.update.test.ts src/cli/update-cli.test.ts src/auto-reply/reply/commands-plugins.install.test.ts src/auto-reply/reply/commands-plugins.test.ts src/commands/onboarding-plugin-install.test.ts src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts src/commands/doctor/shared/codex-route-warnings.test.ts src/commands/doctor/repair-sequencing.test.ts src/agents/auth-profile-runtime-contract.test.ts src/auto-reply/reply/agent-runner-execution.test.ts
- GitHub CI green on 05a2c71b90
Co-authored-by: Codex <noreply@openai.com>
213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js";
|
|
import { resolveStateDir } from "../config/paths.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import {
|
|
tracePluginLifecyclePhase,
|
|
tracePluginLifecyclePhaseAsync,
|
|
} from "../plugins/plugin-lifecycle-trace.js";
|
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|
import { theme } from "../terminal/theme.js";
|
|
import { shortenHomePath } from "../utils.js";
|
|
|
|
export type PluginUninstallOptions = {
|
|
keepFiles?: boolean;
|
|
/** @deprecated Use keepFiles. */
|
|
keepConfig?: boolean;
|
|
force?: boolean;
|
|
dryRun?: boolean;
|
|
};
|
|
|
|
function isPromptInputClosedError(
|
|
error: unknown,
|
|
PromptInputClosedError: typeof import("./prompt.js").PromptInputClosedError,
|
|
): error is InstanceType<typeof PromptInputClosedError> {
|
|
return error instanceof PromptInputClosedError;
|
|
}
|
|
|
|
export async function runPluginUninstallCommand(
|
|
id: string,
|
|
opts: PluginUninstallOptions = {},
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
): Promise<void> {
|
|
assertConfigWriteAllowedInCurrentMode();
|
|
|
|
const {
|
|
loadInstalledPluginIndexInstallRecords,
|
|
removePluginInstallRecordFromRecords,
|
|
withoutPluginInstallRecords,
|
|
withPluginInstallRecords,
|
|
} = await import("../plugins/installed-plugin-index-records.js");
|
|
const { buildPluginSnapshotReport } = await import("../plugins/status.js");
|
|
const {
|
|
applyPluginUninstallDirectoryRemoval,
|
|
formatUninstallActionLabels,
|
|
formatUninstallSlotResetPreview,
|
|
planPluginUninstall,
|
|
resolveUninstallChannelConfigKeys,
|
|
UNINSTALL_ACTION_LABELS,
|
|
} = await import("../plugins/uninstall.js");
|
|
const { commitPluginInstallRecordsWithConfig } =
|
|
await import("./plugins-install-record-commit.js");
|
|
const { refreshPluginRegistryAfterConfigMutation } =
|
|
await import("./plugins-registry-refresh.js");
|
|
const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js");
|
|
const { PromptInputClosedError, promptYesNo } = await import("./prompt.js");
|
|
const snapshot = await tracePluginLifecyclePhaseAsync(
|
|
"config read",
|
|
() => readConfigFileSnapshot(),
|
|
{ command: "uninstall" },
|
|
);
|
|
const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
|
const installRecords = await tracePluginLifecyclePhaseAsync(
|
|
"install records load",
|
|
() => loadInstalledPluginIndexInstallRecords(),
|
|
{ command: "uninstall" },
|
|
);
|
|
const cfg = withPluginInstallRecords(sourceConfig, installRecords);
|
|
const report = tracePluginLifecyclePhase(
|
|
"plugin registry snapshot",
|
|
() => buildPluginSnapshotReport({ config: cfg }),
|
|
{ command: "uninstall" },
|
|
);
|
|
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
|
|
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
|
|
|
|
if (opts.keepConfig) {
|
|
runtime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`."));
|
|
}
|
|
|
|
const { plugin, pluginId } = resolvePluginUninstallId({
|
|
rawId: id,
|
|
config: cfg,
|
|
plugins: report.plugins,
|
|
});
|
|
const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined;
|
|
const plan = planPluginUninstall({
|
|
config: cfg,
|
|
pluginId,
|
|
channelIds,
|
|
deleteFiles: !keepFiles,
|
|
extensionsDir,
|
|
});
|
|
if (!plan.ok) {
|
|
if (plugin) {
|
|
runtime.error(
|
|
`Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`,
|
|
);
|
|
} else {
|
|
runtime.error(plan.error);
|
|
}
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
const hasInstall = pluginId in (cfg.plugins?.installs ?? {});
|
|
|
|
const preview: string[] = [];
|
|
if (plan.actions.entry) {
|
|
preview.push(UNINSTALL_ACTION_LABELS.entry);
|
|
}
|
|
if (plan.actions.install) {
|
|
preview.push(UNINSTALL_ACTION_LABELS.install);
|
|
}
|
|
if (plan.actions.allowlist) {
|
|
preview.push(UNINSTALL_ACTION_LABELS.allowlist);
|
|
}
|
|
if (plan.actions.denylist) {
|
|
preview.push(UNINSTALL_ACTION_LABELS.denylist);
|
|
}
|
|
if (plan.actions.loadPath) {
|
|
preview.push(UNINSTALL_ACTION_LABELS.loadPath);
|
|
}
|
|
if (plan.actions.memorySlot) {
|
|
preview.push(formatUninstallSlotResetPreview("memory"));
|
|
}
|
|
if (plan.actions.contextEngineSlot) {
|
|
preview.push(formatUninstallSlotResetPreview("contextEngine"));
|
|
}
|
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
if (plan.actions.channelConfig && hasInstall && channels) {
|
|
for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) {
|
|
if (Object.hasOwn(channels, key)) {
|
|
preview.push(`${UNINSTALL_ACTION_LABELS.channelConfig} (channels.${key})`);
|
|
}
|
|
}
|
|
}
|
|
if (plan.directoryRemoval) {
|
|
preview.push(`directory: ${shortenHomePath(plan.directoryRemoval.target)}`);
|
|
}
|
|
|
|
const pluginName = plugin?.name || pluginId;
|
|
runtime.log(
|
|
`Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`,
|
|
);
|
|
runtime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`);
|
|
|
|
const nextConfig = withoutPluginInstallRecords(plan.config);
|
|
|
|
if (opts.dryRun) {
|
|
runtime.log(theme.muted("Dry run, no changes made."));
|
|
return;
|
|
}
|
|
|
|
if (!opts.force) {
|
|
let confirmed: boolean;
|
|
try {
|
|
confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
|
|
} catch (error) {
|
|
if (isPromptInputClosedError(error, PromptInputClosedError)) {
|
|
runtime.error(
|
|
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
|
|
);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
if (!confirmed) {
|
|
runtime.log("Cancelled.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
|
|
await tracePluginLifecyclePhaseAsync(
|
|
"config mutation",
|
|
() =>
|
|
commitPluginInstallRecordsWithConfig({
|
|
previousInstallRecords: installRecords,
|
|
nextInstallRecords,
|
|
nextConfig,
|
|
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
|
|
writeOptions: {
|
|
afterWrite: { mode: "restart", reason: "plugin source changed" },
|
|
},
|
|
}),
|
|
{ command: "uninstall" },
|
|
);
|
|
const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
|
|
for (const warning of directoryResult.warnings) {
|
|
runtime.log(theme.warn(warning));
|
|
}
|
|
await refreshPluginRegistryAfterConfigMutation({
|
|
config: nextConfig,
|
|
reason: "source-changed",
|
|
installRecords: nextInstallRecords,
|
|
traceCommand: "uninstall",
|
|
logger: {
|
|
warn: (message) => runtime.log(theme.warn(message)),
|
|
},
|
|
});
|
|
|
|
const removed = formatUninstallActionLabels({
|
|
...plan.actions,
|
|
directory: directoryResult.directoryRemoved,
|
|
});
|
|
|
|
runtime.log(
|
|
`Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`,
|
|
);
|
|
runtime.log("Restart the gateway to apply changes.");
|
|
}
|