fix(plugins): serialize lifecycle mutations and preserve setup-required installs (#112763)

* fix(plugins): harden concurrent lifecycle mutations

* fix(update): preserve post-core restart snapshot

* fix(update): retain authoritative plugin records on resume

* fix(update): reconcile plugin records after handoff
This commit is contained in:
Peter Steinberger
2026-07-22 23:25:48 -04:00
committed by GitHub
parent 67ef07863f
commit a12e0f26ee
22 changed files with 1187 additions and 336 deletions
+3 -1
View File
@@ -182,7 +182,9 @@ non-npm sources are not rewritten.
<Accordion title="Config includes and invalid-config repair">
If your `plugins` section is backed by a single-file `$include`, `plugins install/update/enable/disable/uninstall` write through to that included file and leave `openclaw.json` untouched. Root includes, include arrays, and includes with sibling overrides fail closed instead of flattening. See [Config includes](/gateway/configuration) for the supported shapes.
If config is invalid during install, `plugins install` normally fails closed and tells you to run `openclaw doctor --fix` first. During Gateway startup and hot reload, invalid plugin config fails closed like any other invalid config; `openclaw doctor --fix` can quarantine the invalid plugin entry. The only documented install-time exception is a narrow bundled-plugin recovery path for plugins that explicitly opt into `openclaw.install.allowInvalidConfigRecovery`.
If config is invalid before install, `plugins install` normally fails closed and tells you to run `openclaw doctor --fix` first. During Gateway startup and hot reload, invalid plugin config fails closed like any other invalid config; `openclaw doctor --fix` can quarantine the invalid plugin entry. The only pre-existing-config exception is a narrow bundled-plugin recovery path for plugins that explicitly opt into `openclaw.install.allowInvalidConfigRecovery`.
When the existing host config is valid but the newly installed plugin's own config is absent, OpenClaw records the install disabled instead of writing an invalid enabled entry. Configure `plugins.entries.<id>.config`, then run `openclaw plugins enable <id>`. If an existing plugin config entry is present but invalid, install fails without rewriting it.
</Accordion>
<Accordion title="--force confirmation and reinstall vs update">
+5
View File
@@ -138,6 +138,11 @@ ClawHub, or hook-pack install, use `openclaw plugins update` instead. With
`--link`, `--force` only confirms the source; the linked directory is not
copied or overwritten.
If a newly installed plugin requires configuration that is not present yet,
OpenClaw records the install but leaves the plugin disabled. Configure
`plugins.entries.<id>.config`, then run `openclaw plugins enable <id>`. If an
existing config entry is present but invalid, install fails without rewriting it.
## Restart and inspect
A running managed Gateway with config reload enabled restarts automatically
+108 -99
View File
@@ -11,6 +11,7 @@ import {
type ConfigSnapshotForInstallPersist,
} from "../../plugins/install-persistence.js";
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js";
import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
import type { PluginRecord } from "../../plugins/registry.js";
import {
@@ -274,134 +275,142 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
}
if (pluginsCommand.action === "install") {
const loadedConfig = await loadPluginCommandConfig();
if (!loadedConfig.ok) {
return await withPluginLifecycleLease({}, async () => {
const loadedConfig = await loadPluginCommandConfig();
if (!loadedConfig.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${loadedConfig.error}` },
};
}
const installed = await installPluginFromPluginsCommand({
raw: pluginsCommand.spec,
force: pluginsCommand.force,
config: loadedConfig.snapshot.config,
snapshot: loadedConfig.snapshot,
});
if (!installed.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${installed.error}` },
};
}
return {
shouldContinue: false,
reply: { text: `⚠️ ${loadedConfig.error}` },
reply: {
text: [
`🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`,
...(installed.warnings ?? []).map((warning) => `⚠️ ${warning}`),
].join("\n"),
},
};
}
const installed = await installPluginFromPluginsCommand({
raw: pluginsCommand.spec,
force: pluginsCommand.force,
config: loadedConfig.snapshot.config,
snapshot: loadedConfig.snapshot,
});
if (!installed.ok) {
}
const handleLoadedCommand = async () => {
const loaded = await loadPluginCommandState(params.workspaceDir, {
loadModules: pluginsCommand.action === "inspect",
});
if (!loaded.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${installed.error}` },
reply: { text: `⚠️ ${loaded.error}` },
};
}
return {
shouldContinue: false,
reply: {
text: [
`🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`,
...(installed.warnings ?? []).map((warning) => `⚠️ ${warning}`),
].join("\n"),
},
};
}
const loaded = await loadPluginCommandState(params.workspaceDir, {
loadModules: pluginsCommand.action === "inspect",
});
if (!loaded.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${loaded.error}` },
};
}
if (pluginsCommand.action === "list") {
return {
shouldContinue: false,
reply: { text: formatPluginsList(loaded.report) },
};
}
if (pluginsCommand.action === "inspect") {
const installRecords = await loadInstalledPluginIndexInstallRecords();
if (!pluginsCommand.name) {
if (pluginsCommand.action === "list") {
return {
shouldContinue: false,
reply: { text: formatPluginsList(loaded.report) },
};
}
if (normalizeOptionalLowercaseString(pluginsCommand.name) === "all") {
if (pluginsCommand.action === "inspect") {
const installRecords = await loadInstalledPluginIndexInstallRecords();
if (!pluginsCommand.name) {
return {
shouldContinue: false,
reply: { text: formatPluginsList(loaded.report) },
};
}
if (normalizeOptionalLowercaseString(pluginsCommand.name) === "all") {
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(
"🔌 Plugins",
buildAllPluginInspectJson({ ...loaded, installRecords }),
),
},
};
}
const payload = buildPluginInspectJson({
id: pluginsCommand.name,
config: loaded.config,
installRecords,
report: loaded.report,
});
if (!payload) {
return {
shouldContinue: false,
reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` },
};
}
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(
"🔌 Plugins",
buildAllPluginInspectJson({ ...loaded, installRecords }),
),
text: renderJsonBlock(`🔌 Plugin "${payload.inspect.plugin.id}"`, {
...payload.inspect,
compatibilityWarnings: payload.compatibilityWarnings,
install: payload.install,
}),
},
};
}
const payload = buildPluginInspectJson({
id: pluginsCommand.name,
config: loaded.config,
installRecords,
report: loaded.report,
});
if (!payload) {
const plugin = findPlugin(loaded.report, pluginsCommand.name);
if (!plugin) {
return {
shouldContinue: false,
reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` },
};
}
let registryWarning: string | undefined;
try {
const committedConfig = await setPluginEnabledFromCommand({
pluginId: plugin.id,
enabled: pluginsCommand.action === "enable",
action: pluginsCommand.action,
});
await refreshPluginRegistryAfterConfigMutation({
config: committedConfig,
reason: "policy-changed",
logger: {
warn: (message) => {
registryWarning = message;
},
},
});
} catch (error) {
if (error instanceof AutoReplyConfigMutationError) {
return { shouldContinue: false, reply: { text: `⚠️ ${error.message}` } };
}
throw error;
}
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(`🔌 Plugin "${payload.inspect.plugin.id}"`, {
...payload.inspect,
compatibilityWarnings: payload.compatibilityWarnings,
install: payload.install,
}),
text:
`🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Gateway reload will apply it to new agent turns.` +
(registryWarning ? `\n${registryWarning}` : ""),
},
};
}
const plugin = findPlugin(loaded.report, pluginsCommand.name);
if (!plugin) {
return {
shouldContinue: false,
reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` },
};
}
let committedConfig: OpenClawConfig;
try {
committedConfig = await setPluginEnabledFromCommand({
pluginId: plugin.id,
enabled: pluginsCommand.action === "enable",
action: pluginsCommand.action,
});
} catch (error) {
if (error instanceof AutoReplyConfigMutationError) {
return { shouldContinue: false, reply: { text: `⚠️ ${error.message}` } };
}
throw error;
}
let registryWarning: string | undefined;
await refreshPluginRegistryAfterConfigMutation({
config: committedConfig,
reason: "policy-changed",
logger: {
warn: (message) => {
registryWarning = message;
},
},
});
return {
shouldContinue: false,
reply: {
text:
`🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Gateway reload will apply it to new agent turns.` +
(registryWarning ? `\n${registryWarning}` : ""),
},
};
if (pluginsCommand.action === "enable" || pluginsCommand.action === "disable") {
return await withPluginLifecycleLease({}, handleLoadedCommand);
}
return await handleLoadedCommand();
};
+19 -1
View File
@@ -1,6 +1,7 @@
import { runPluginUninstallCommand } from "../cli/plugins-uninstall-command.js";
import { normalizeClawHubSha256Integrity } from "../infra/clawhub.js";
import { resolveInstalledClawHubPlugin } from "../plugins/plugin-install-preflight.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import {
applyClawHubSkillUninstall,
planClawHubSkillUninstall,
@@ -360,9 +361,26 @@ export async function planClawPackageRemovals(
return decisions;
}
type ApplyClawPackageRemovalOptions = OpenClawStateDatabaseOptions & {
deps?: PackageRemovalDeps;
};
export async function applyClawPackageRemovals(
decisions: ClawPackageRemovalDecision[],
options: OpenClawStateDatabaseOptions & { deps?: PackageRemovalDeps } = {},
options: ApplyClawPackageRemovalOptions = {},
): Promise<ClawPackageRemovalResult[]> {
if (!decisions.some((decision) => decision.packageRef.kind === "plugin")) {
return await applyClawPackageRemovalsUnlocked(decisions, options);
}
return await withPluginLifecycleLease(
{},
async () => await applyClawPackageRemovalsUnlocked(decisions, options),
);
}
async function applyClawPackageRemovalsUnlocked(
decisions: ClawPackageRemovalDecision[],
options: ApplyClawPackageRemovalOptions,
): Promise<ClawPackageRemovalResult[]> {
const deps = options.deps ?? {};
const results: ClawPackageRemovalResult[] = [];
+25 -6
View File
@@ -6,6 +6,7 @@ import {
preflightPluginInstall,
resolveInstalledClawHubPlugin,
} from "../plugins/plugin-install-preflight.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { installSkillFromClawHub, preflightSkillFromClawHub } from "../skills/lifecycle/clawhub.js";
import {
@@ -207,14 +208,32 @@ export async function preflightClawPackage(
};
}
type InstallClawPackagesOptions = OpenClawStateDatabaseOptions & {
deps?: PackageInstallerDeps;
runtime?: RuntimeEnv;
nowMs?: number;
onExternalMutation?: (pkg: ClawPackage) => void;
};
export async function installClawPackages(
plan: ClawAddPlan,
options: OpenClawStateDatabaseOptions & {
deps?: PackageInstallerDeps;
runtime?: RuntimeEnv;
nowMs?: number;
onExternalMutation?: (pkg: ClawPackage) => void;
} = {},
options: InstallClawPackagesOptions = {},
): Promise<PersistedClawPackageRef[]> {
const includesPlugin = plan.actions.some(
(action) => action.kind === "package" && action.details?.kind === "plugin",
);
if (!includesPlugin) {
return await installClawPackagesUnlocked(plan, options);
}
return await withPluginLifecycleLease(
{},
async () => await installClawPackagesUnlocked(plan, options),
);
}
async function installClawPackagesUnlocked(
plan: ClawAddPlan,
options: InstallClawPackagesOptions,
): Promise<PersistedClawPackageRef[]> {
const deps = options.deps ?? {};
const installPlugin = deps.installPlugin ?? runPluginInstallCommand;
+38 -3
View File
@@ -1766,13 +1766,13 @@ describe("plugins cli install", () => {
expect(clawHubInstallCall().spec).toBe("clawhub:demo");
});
it("does not persist incomplete config entries for config-gated bundled installs", async () => {
it("preserves non-config policy for unconfigured bundled installs", async () => {
const pluginId = "config-required-plugin";
const cfg = {
plugins: {
entries: {
[pluginId]: {
config: {},
hooks: { timeoutMs: 5_000 },
},
},
load: {
@@ -1801,7 +1801,10 @@ describe("plugins cli install", () => {
const writtenConfig = writeConfigFile.mock.calls[
writeConfigFile.mock.calls.length - 1
]?.[0] as OpenClawConfig;
expect(writtenConfig.plugins?.entries?.[pluginId]).toBeUndefined();
expect(writtenConfig.plugins?.entries?.[pluginId]).toEqual({
enabled: false,
hooks: { timeoutMs: 5_000 },
});
expect(writtenConfig.plugins?.load?.paths).toEqual(["/existing/plugin"]);
const record = persistedInstallRecord(pluginId);
expect(record.source).toBe("path");
@@ -1812,6 +1815,38 @@ describe("plugins cli install", () => {
expect(runtimeLogsContain("requires configuration first")).toBe(true);
});
it("rejects invalid authored config for config-gated bundled installs", async () => {
const pluginId = "config-required-plugin";
const cfg = {
plugins: {
entries: {
[pluginId]: {
config: {},
hooks: { timeoutMs: 5_000 },
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue({
pluginId,
localPath: `/app/dist/extensions/${pluginId}`,
configSchema: {
type: "object",
required: ["token"],
properties: { token: { type: "string" } },
},
requiresConfig: true,
});
await expect(runPluginsCommand(["plugins", "install", pluginId])).rejects.toThrow(
"has invalid configured settings",
);
expect(writeConfigFile).not.toHaveBeenCalled();
expect(enablePluginInConfig).not.toHaveBeenCalled();
});
it("enables config-gated bundled installs when provider-backed config is explicit", async () => {
const pluginId = "config-required-plugin";
const cfg = {
+17
View File
@@ -14,6 +14,7 @@ import {
} from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { emitDiagnosticsTimelineEvent } from "../infra/diagnostics-timeline.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomeInString } from "../utils.js";
@@ -188,6 +189,14 @@ function collectConfiguredRuntimePluginWarnings(params: {
/** Enable a plugin in config and refresh the registry snapshot for the changed policy. */
export async function runPluginsEnableCommand(idInput: string): Promise<void> {
assertConfigWriteAllowedInCurrentMode();
return await withPluginLifecycleLease(
{},
async () => await runPluginsEnableCommandUnlocked(idInput),
);
}
async function runPluginsEnableCommandUnlocked(idInput: string): Promise<void> {
let id = idInput;
assertConfigWriteAllowedInCurrentMode();
@@ -235,6 +244,14 @@ export async function runPluginsEnableCommand(idInput: string): Promise<void> {
/** Disable a plugin in config and refresh the registry snapshot for the changed policy. */
export async function runPluginsDisableCommand(idInput: string): Promise<void> {
assertConfigWriteAllowedInCurrentMode();
return await withPluginLifecycleLease(
{},
async () => await runPluginsDisableCommandUnlocked(idInput),
);
}
async function runPluginsDisableCommandUnlocked(idInput: string): Promise<void> {
let id = idInput;
assertConfigWriteAllowedInCurrentMode();
+68 -5
View File
@@ -86,7 +86,8 @@ describe("plugins cli uninstall", () => {
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("shows uninstall dry-run preview without mutating config", async () => {
it("shows uninstall dry-run preview without mutating config or acquiring write mode", async () => {
process.env.OPENCLAW_NIX_MODE = "1";
loadConfig.mockReturnValue({
plugins: {
entries: {
@@ -198,12 +199,12 @@ describe("plugins cli uninstall", () => {
entries: {},
},
},
writeOptions: {
writeOptions: expect.objectContaining({
allowConfigSizeDrop: true,
auditOrigin: "plugin-install",
afterWrite: { mode: "restart", reason: "plugin source changed" },
unsetPaths: [["plugins", "installs"]],
},
}),
});
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: {
@@ -450,18 +451,80 @@ describe("plugins cli uninstall", () => {
const configWriteOrder = writeConfigFile.mock.invocationCallOrder[0] ?? 0;
const deleteOrder =
applyPluginUninstallDirectoryRemoval.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER;
const finalConfigWriteOrder =
writeConfigFile.mock.invocationCallOrder[1] ?? Number.MAX_SAFE_INTEGER;
const refreshOrder =
refreshPluginRegistry.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER;
expect(writeConfigFile).toHaveBeenCalledTimes(1);
expect(writeConfigFile).toHaveBeenCalledTimes(2);
expect(applyPluginUninstallDirectoryRemoval).toHaveBeenCalledTimes(1);
expect(refreshPluginRegistry).toHaveBeenCalledTimes(1);
expect(deleteOrder).toBeGreaterThan(configWriteOrder);
expect(refreshOrder).toBeGreaterThan(deleteOrder);
expect(finalConfigWriteOrder).toBeGreaterThan(deleteOrder);
expect(refreshOrder).toBeGreaterThan(finalConfigWriteOrder);
expect(applyPluginUninstallDirectoryRemoval).toHaveBeenCalledWith({
target: ALPHA_INSTALL_PATH,
});
});
it("keeps the install tracked and disabled when directory removal fails", async () => {
const installPath = tempDirs.make("openclaw-plugin-uninstall-failure-");
const installRecords = {
alpha: {
source: "npm",
spec: "alpha@1.0.0",
installPath,
},
} as const;
const baseConfig = {
plugins: {
entries: {
alpha: { enabled: true },
},
installs: installRecords,
},
} as OpenClawConfig;
loadConfig.mockReturnValue(baseConfig);
setInstalledPluginIndexInstallRecords(installRecords);
buildPluginSnapshotReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
});
planPluginUninstall.mockReturnValue({
ok: true,
config: { plugins: { entries: {}, installs: {} } } as OpenClawConfig,
actions: {
entry: true,
install: true,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
directory: false,
},
directoryRemoval: { target: installPath },
});
applyPluginUninstallDirectoryRemoval.mockResolvedValue({
directoryRemoved: false,
warnings: ["simulated removal failure"],
});
await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
"remains disabled and tracked",
);
expect(writeConfigFile).toHaveBeenCalledWith({
plugins: {
entries: {
alpha: { enabled: false },
},
installs: installRecords,
},
});
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
});
it("cleans stale policy refs even when plugin is absent from the current registry", async () => {
const baseConfig = {
plugins: {
+13 -2
View File
@@ -43,6 +43,7 @@ import {
resolveMarketplaceInstallShortcut,
} from "../plugins/marketplace.js";
import { resolveCatalogOfficialExternalInstallPlan } from "../plugins/official-external-install-trust.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { markClawPackageIndependentlyOwned } from "../state/claw-package-adoption.js";
@@ -757,7 +758,7 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") {
] = { loadConfigForInstall };
}
export async function runPluginInstallCommand(params: {
type RunPluginInstallCommandParams = {
raw: string;
opts: InstallSafetyOverrides & {
acknowledgeClawHubRisk?: boolean;
@@ -771,7 +772,17 @@ export async function runPluginInstallCommand(params: {
invalidateRuntimeCache?: boolean;
clawManaged?: boolean;
runtime?: RuntimeEnv;
}) {
};
export async function runPluginInstallCommand(params: RunPluginInstallCommandParams) {
assertConfigWriteAllowedInCurrentMode();
return await withPluginLifecycleLease(
{},
async () => await runPluginInstallCommandUnlocked(params),
);
}
async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandParams) {
assertConfigWriteAllowedInCurrentMode();
const runtime = params.runtime ?? defaultRuntime;
+115 -25
View File
@@ -2,10 +2,15 @@
import os from "node:os";
import path from "node:path";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js";
import {
assertConfigWriteAllowedInCurrentMode,
readConfigFileSnapshotForWrite,
replaceConfigFile,
} from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import {
tracePluginLifecyclePhase,
tracePluginLifecyclePhaseAsync,
@@ -37,8 +42,29 @@ export async function runPluginUninstallCommand(
opts: PluginUninstallOptions = {},
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
// Uninstall mutates config/install records and optionally managed files, so guard write mode first.
if (opts.dryRun) {
return await runPluginUninstallCommandUnlocked(id, opts, runtime);
}
assertConfigWriteAllowedInCurrentMode();
if (!opts.force) {
return await runPluginUninstallCommandUnlocked(id, opts, runtime);
}
return await withPluginLifecycleLease(
{},
async () => await runPluginUninstallCommandUnlocked(id, opts, runtime),
);
}
async function runPluginUninstallCommandUnlocked(
id: string,
opts: PluginUninstallOptions,
runtime: RuntimeEnv,
skipPreview = false,
): Promise<void> {
// Dry-run only reads state; real uninstalls fail before any lifecycle lease or mutation.
if (!opts.dryRun) {
assertConfigWriteAllowedInCurrentMode();
}
const {
loadInstalledPluginIndexInstallRecords,
@@ -52,20 +78,25 @@ export async function runPluginUninstallCommand(
formatUninstallActionLabels,
formatUninstallSlotResetPreview,
planPluginUninstall,
pluginUninstallTargetExists,
prepareConfigForPendingPluginDirectoryRemoval,
resolveUninstallChannelConfigKeys,
UNINSTALL_ACTION_LABELS,
} = await import("../plugins/uninstall.js");
const { commitPluginInstallRecordsWithConfig } =
await import("../plugins/install-record-commit.js");
const { selectInstallMutationWriteOptions } = await import("../plugins/install-persistence.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(
const prepared = await tracePluginLifecyclePhaseAsync(
"config read",
() => readConfigFileSnapshot(),
() => readConfigFileSnapshotForWrite(),
{ command: "uninstall" },
);
const { snapshot } = prepared;
const mutationWriteOptions = selectInstallMutationWriteOptions(prepared.writeOptions);
const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const installRecords = await tracePluginLifecyclePhaseAsync(
"install records load",
@@ -91,24 +122,25 @@ export async function runPluginUninstallCommand(
plugins: report.plugins,
});
const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined;
const plan = planPluginUninstall({
const initialPlan = planPluginUninstall({
config: cfg,
pluginId,
channelIds,
deleteFiles: !keepFiles,
extensionsDir,
});
if (!plan.ok) {
if (!initialPlan.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.error(initialPlan.error);
}
runtime.exit(1);
return;
}
let plan = initialPlan;
const hasInstall = Object.hasOwn(cfg.plugins?.installs ?? {}, pluginId);
const preview: string[] = [];
@@ -145,22 +177,24 @@ export async function runPluginUninstallCommand(
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)"}`);
if (!skipPreview) {
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 { collectClawPluginUninstallWarnings } =
await import("../plugins/uninstall-claw-references.js");
for (const warning of collectClawPluginUninstallWarnings({
pluginId,
installRecord: cfg.plugins?.installs?.[pluginId],
})) {
runtime.log(theme.warn(warning));
const { collectClawPluginUninstallWarnings } =
await import("../plugins/uninstall-claw-references.js");
for (const warning of collectClawPluginUninstallWarnings({
pluginId,
installRecord: cfg.plugins?.installs?.[pluginId],
})) {
runtime.log(theme.warn(warning));
}
}
const nextConfig = withoutPluginInstallRecords(plan.config);
let nextConfig = withoutPluginInstallRecords(plan.config);
if (opts.dryRun) {
runtime.log(theme.muted("Dry run, no changes made."));
@@ -185,9 +219,66 @@ export async function runPluginUninstallCommand(
runtime.log("Cancelled.");
return;
}
return await withPluginLifecycleLease(
{},
async () =>
await runPluginUninstallCommandUnlocked(id, { ...opts, force: true }, runtime, true),
);
}
const uninstall = async () => {
let finalBaseHash = snapshot.hash;
let finalWriteOptions = mutationWriteOptions;
let directoryResult = { directoryRemoved: false, warnings: [] as string[] };
if (plan.directoryRemoval) {
const disabledConfig = prepareConfigForPendingPluginDirectoryRemoval(sourceConfig, pluginId);
const disabledCommit = await tracePluginLifecyclePhaseAsync(
"config disable",
() =>
replaceConfigFile({
nextConfig: disabledConfig,
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
writeOptions: {
...mutationWriteOptions,
afterWrite: { mode: "auto" },
},
}),
{ command: "uninstall" },
);
finalBaseHash = disabledCommit?.persistedHash ?? snapshot.hash;
directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
for (const warning of directoryResult.warnings) {
runtime.log(theme.warn(warning));
}
if (pluginUninstallTargetExists(plan.directoryRemoval.target)) {
throw new Error(
`Failed to remove plugin directory ${shortenHomePath(plan.directoryRemoval.target)}; the plugin remains disabled and tracked so uninstall can be retried.`,
);
}
const refreshedPrepared = await tracePluginLifecyclePhaseAsync(
"config reread",
() => readConfigFileSnapshotForWrite(),
{ command: "uninstall" },
);
const refreshedSnapshot = refreshedPrepared.snapshot;
const refreshedSourceConfig = (refreshedSnapshot.sourceConfig ??
refreshedSnapshot.config) as OpenClawConfig;
const refreshedPlan = planPluginUninstall({
config: withPluginInstallRecords(refreshedSourceConfig, installRecords),
pluginId,
channelIds,
deleteFiles: true,
extensionsDir,
});
if (!refreshedPlan.ok) {
throw new Error(refreshedPlan.error);
}
plan = refreshedPlan;
nextConfig = withoutPluginInstallRecords(plan.config);
finalBaseHash = refreshedSnapshot.hash;
finalWriteOptions = selectInstallMutationWriteOptions(refreshedPrepared.writeOptions);
}
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
await tracePluginLifecyclePhaseAsync(
"config mutation",
@@ -196,18 +287,17 @@ export async function runPluginUninstallCommand(
previousInstallRecords: installRecords,
nextInstallRecords,
nextConfig,
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
...(finalBaseHash !== undefined ? { baseHash: finalBaseHash } : {}),
writeOptions: {
...finalWriteOptions,
allowConfigSizeDrop: true,
auditOrigin: "plugin-install",
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));
if (!plan.directoryRemoval) {
directoryResult = await applyPluginUninstallDirectoryRemoval(null);
}
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
+17 -3
View File
@@ -35,6 +35,7 @@ import {
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../plugins/installed-plugin-index-records.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import { refreshPluginRegistryAfterConfigMutation } from "../plugins/registry-refresh.js";
import {
isPluginInstallRecordUpdateSource,
@@ -170,8 +171,7 @@ async function assertRecordsOnlyUpdateConfigFresh(params: {
}
}
/** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */
export async function runPluginUpdateCommand(params: {
type RunPluginUpdateCommandParams = {
id?: string;
opts: {
all?: boolean;
@@ -179,7 +179,21 @@ export async function runPluginUpdateCommand(params: {
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
};
}) {
};
/** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */
export async function runPluginUpdateCommand(params: RunPluginUpdateCommandParams) {
assertConfigWriteAllowedInCurrentMode();
if (params.opts.dryRun) {
return await runPluginUpdateCommandUnlocked(params);
}
return await withPluginLifecycleLease(
{},
async () => await runPluginUpdateCommandUnlocked(params),
);
}
async function runPluginUpdateCommandUnlocked(params: RunPluginUpdateCommandParams) {
assertConfigWriteAllowedInCurrentMode();
const sourceSnapshotPromise = readConfigFileSnapshotForWrite()
+52 -49
View File
@@ -44,6 +44,7 @@ import {
loadInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecords,
} from "../../plugins/installed-plugin-index-records.js";
import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js";
import { runExec } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
import { VERSION } from "../../version.js";
@@ -87,7 +88,7 @@ export const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE
export const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
export const POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV =
"OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH";
const POST_CORE_UPDATE_STARTED_AT_ENV = "OPENCLAW_UPDATE_POST_CORE_STARTED_AT_MS";
export const POST_CORE_UPDATE_STARTED_AT_ENV = "OPENCLAW_UPDATE_POST_CORE_STARTED_AT_MS";
const POST_CORE_UPDATE_RESULT_POLL_MS = 100;
export async function reportPreMutationUpdateFailure(params: {
@@ -195,56 +196,58 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
});
}
const initialPluginUpdate = await withUpdateFinalizationEnv(async () => {
await createUpdateConfigSnapshot();
await doctorCommand(defaultRuntime, {
nonInteractive: true,
repair: true,
yes: opts.yes === true,
});
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
if (requestedChannel) {
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
const completedPluginUpdate = await withPluginLifecycleLease({}, async () => {
const initialPluginUpdate = await withUpdateFinalizationEnv(async () => {
await createUpdateConfigSnapshot();
await doctorCommand(defaultRuntime, {
nonInteractive: true,
repair: true,
yes: opts.yes === true,
});
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
if (requestedChannel) {
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
}
const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig);
configSnapshot = restoredConfig.snapshot;
const postDoctorStoredChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
const postDoctorChannel =
requestedChannel ??
postDoctorStoredChannel ??
storedChannel ??
effectiveChannel ??
DEFAULT_PACKAGE_CHANNEL;
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
return await updatePluginsAfterCoreUpdate({
root,
channel: postDoctorChannel,
configSnapshot,
configChanged: restoredConfig.changed,
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: {
json: opts.json,
timeout: opts.timeout,
yes: opts.yes,
restart: false,
acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk,
},
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
pluginInstallRecords,
});
}
const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig);
configSnapshot = restoredConfig.snapshot;
const postDoctorStoredChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
const postDoctorChannel =
requestedChannel ??
postDoctorStoredChannel ??
storedChannel ??
effectiveChannel ??
DEFAULT_PACKAGE_CHANNEL;
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
return await updatePluginsAfterCoreUpdate({
root,
channel: postDoctorChannel,
configSnapshot,
configChanged: restoredConfig.changed,
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: {
json: opts.json,
timeout: opts.timeout,
yes: opts.yes,
restart: false,
acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk,
},
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
pluginInstallRecords,
});
});
const completedPluginUpdate = await completePostCorePluginUpdate({
root,
pluginUpdate: initialPluginUpdate,
freshDoctorRequired: initialPluginUpdate.changed,
yes: opts.yes === true,
json: opts.json === true,
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
return await completePostCorePluginUpdate({
root,
pluginUpdate: initialPluginUpdate,
freshDoctorRequired: initialPluginUpdate.changed,
yes: opts.yes === true,
json: opts.json === true,
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
});
});
const pluginUpdate = completedPluginUpdate.pluginUpdate;
configSnapshot = completedPluginUpdate.configSnapshot;
@@ -11,6 +11,7 @@ import {
} from "../../infra/update-control-plane-sentinel.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js";
import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js";
import { defaultRuntime } from "../../runtime.js";
import { VERSION } from "../../version.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
@@ -162,16 +163,7 @@ export async function finishUpdate(params: {
downgradeRisk: params.downgradeRisk,
});
let postUpdateConfigSnapshot = await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: shouldResumePostCoreInFreshProcess,
});
if (!shouldResumePostCoreInFreshProcess) {
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot: postUpdateConfigSnapshot,
requestedChannel: params.requestedChannel,
});
}
let postUpdateConfigSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | undefined;
if (
params.requestedChannel &&
params.configSnapshot.valid &&
@@ -224,74 +216,78 @@ export async function finishUpdate(params: {
}
if (!pluginsUpdatedInFreshProcess) {
if (shouldResumePostCoreInFreshProcess) {
await withPluginLifecycleLease({}, async () => {
postUpdateConfigSnapshot = await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: shouldResumePostCoreInFreshProcess,
});
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot: postUpdateConfigSnapshot,
requestedChannel: params.requestedChannel,
});
}
const restoredConfig = restoreDroppedPreUpdateChannels(
postUpdateConfigSnapshot,
params.configSnapshot.valid
? {
sourceConfig: params.configSnapshot.sourceConfig,
authoredConfig: isRecord(params.configSnapshot.parsed)
? (params.configSnapshot.parsed as OpenClawConfig)
: params.configSnapshot.sourceConfig,
}
: undefined,
);
postUpdateConfigSnapshot = restoredConfig.snapshot;
// Current-process post-core convergence still reports the pre-update
// VERSION. During downgrades, pin compatibility checks to the installed
// target so incompatible newer plugins are disabled before restart.
const postUpdateInstalledVersion = await readPackageVersion(postUpdateRoot);
const versionComparison =
postUpdateInstalledVersion && VERSION
? compareSemverStrings(VERSION, postUpdateInstalledVersion)
: null;
const compatibilityDowngradeTarget =
versionComparison != null && versionComparison > 0 ? postUpdateInstalledVersion : null;
const previousCompatibilityHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
if (compatibilityDowngradeTarget) {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget;
}
try {
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
root: postUpdateRoot,
channel: params.channel,
configSnapshot: postUpdateConfigSnapshot,
configChanged: restoredConfig.changed,
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: params.opts,
timeoutMs: params.updateStepTimeoutMs,
pluginInstallRecords: params.preUpdatePluginInstallRecords,
});
const completedPluginUpdate = await completePostCorePluginUpdate({
root: postUpdateRoot,
pluginUpdate: initialPluginUpdate,
// A plugin-only update can replace its migration owner without replacing core.
// Downgrades and resume fallbacks can also leave an updated core on disk in this process.
freshDoctorRequired:
didCoreUpdateChangeInstall(params.result) ||
initialPluginUpdate.sync.changed ||
initialPluginUpdate.npm.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.updateStepTimeoutMs,
...(params.packageUpdateNodeRunner ? { nodeRunner: params.packageUpdateNodeRunner } : {}),
});
postCorePluginUpdate = completedPluginUpdate.pluginUpdate;
postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot;
} finally {
const restoredConfig = restoreDroppedPreUpdateChannels(
postUpdateConfigSnapshot,
params.configSnapshot.valid
? {
sourceConfig: params.configSnapshot.sourceConfig,
authoredConfig: isRecord(params.configSnapshot.parsed)
? (params.configSnapshot.parsed as OpenClawConfig)
: params.configSnapshot.sourceConfig,
}
: undefined,
);
postUpdateConfigSnapshot = restoredConfig.snapshot;
// Current-process post-core convergence still reports the pre-update
// VERSION. During downgrades, pin compatibility checks to the installed
// target so incompatible newer plugins are disabled before restart.
const postUpdateInstalledVersion = await readPackageVersion(postUpdateRoot);
const versionComparison =
postUpdateInstalledVersion && VERSION
? compareSemverStrings(VERSION, postUpdateInstalledVersion)
: null;
const compatibilityDowngradeTarget =
versionComparison != null && versionComparison > 0 ? postUpdateInstalledVersion : null;
const previousCompatibilityHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
if (compatibilityDowngradeTarget) {
if (previousCompatibilityHostVersion === undefined) {
delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
} else {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousCompatibilityHostVersion;
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget;
}
try {
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
root: postUpdateRoot,
channel: params.channel,
configSnapshot: postUpdateConfigSnapshot,
configChanged: restoredConfig.changed,
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: params.opts,
timeoutMs: params.updateStepTimeoutMs,
pluginInstallRecords: params.preUpdatePluginInstallRecords,
});
const completedPluginUpdate = await completePostCorePluginUpdate({
root: postUpdateRoot,
pluginUpdate: initialPluginUpdate,
// A plugin-only update can replace its migration owner without replacing core.
// Downgrades and resume fallbacks can also leave an updated core on disk in this process.
freshDoctorRequired:
didCoreUpdateChangeInstall(params.result) ||
initialPluginUpdate.sync.changed ||
initialPluginUpdate.npm.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.updateStepTimeoutMs,
...(params.packageUpdateNodeRunner ? { nodeRunner: params.packageUpdateNodeRunner } : {}),
});
postCorePluginUpdate = completedPluginUpdate.pluginUpdate;
postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot;
} finally {
if (compatibilityDowngradeTarget) {
if (previousCompatibilityHostVersion === undefined) {
delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
} else {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousCompatibilityHostVersion;
}
}
}
}
});
}
const resultWithPostUpdate: UpdateRunResult = postCorePluginUpdate
@@ -332,12 +328,18 @@ export async function finishUpdate(params: {
return;
}
const restartConfigSnapshot =
postUpdateConfigSnapshot ??
(await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: shouldResumePostCoreInFreshProcess,
}));
let restartScriptPath: string | null = null;
let refreshGatewayServiceEnv = false;
let gatewayServiceEnv: NodeJS.ProcessEnv | undefined;
let skipLegacyServiceRestart = false;
let gatewayPort = resolveUpdatedGatewayRestartPort({
config: postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined,
config: restartConfigSnapshot.valid ? restartConfigSnapshot.config : undefined,
processEnv: process.env,
});
if (params.shouldRestart) {
@@ -382,7 +384,7 @@ export async function finishUpdate(params: {
) {
gatewayServiceEnv = serviceState.env;
gatewayPort = resolveUpdatedGatewayRestartPort({
config: postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined,
config: restartConfigSnapshot.valid ? restartConfigSnapshot.config : undefined,
processEnv: process.env,
serviceEnv: gatewayServiceEnv,
});
+27 -8
View File
@@ -3,6 +3,8 @@ import { normalizeUpdateChannel } from "../../infra/update-channels.js";
import { POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV } from "../../infra/update-post-core-context.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js";
import { readPersistedInstalledPluginIndex } from "../../plugins/installed-plugin-index-store.js";
import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js";
import { defaultRuntime } from "../../runtime.js";
import { VERSION } from "../../version.js";
import { readPackageVersion, type UpdateCommandOptions } from "./shared.js";
@@ -17,17 +19,24 @@ import {
POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV,
POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV,
POST_CORE_UPDATE_RESULT_PATH_ENV,
POST_CORE_UPDATE_STARTED_AT_ENV,
readPostCorePluginInstallRecordsFile,
resolvePostCoreUpdateStartedAtMs,
writePostCorePluginUpdateResultFile,
} from "./update-command-post-core.js";
export async function resumePostCoreUpdate(params: {
type ResumePostCoreUpdateParams = {
root: string;
channel: string | undefined;
opts: UpdateCommandOptions;
timeoutMs: number;
}): Promise<void> {
};
export async function resumePostCoreUpdate(params: ResumePostCoreUpdateParams): Promise<void> {
return await withPluginLifecycleLease({}, async () => await resumePostCoreUpdateUnlocked(params));
}
async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams): Promise<void> {
if (
params.channel !== "stable" &&
params.channel !== "extended-stable" &&
@@ -56,10 +65,11 @@ export async function resumePostCoreUpdate(params: {
skipPluginValidation: true,
suppressFutureVersionWarning: true,
});
const updateStartedAtMs = await resolvePostCoreUpdateStartedAtMs(process.env);
const preUpdateSourceConfig = await readPostCorePreUpdateSourceConfig({
sourceConfigPath: process.env[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV],
currentSnapshot: configSnapshot,
updateStartedAtMs: await resolvePostCoreUpdateStartedAtMs(process.env),
updateStartedAtMs,
});
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
@@ -69,12 +79,21 @@ export async function resumePostCoreUpdate(params: {
const parentPluginInstallRecords = await readPostCorePluginInstallRecordsFile(
process.env[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV],
);
// The updated doctor may have repaired plugin installs before this fresh process resumed.
// The updated doctor may have repaired or removed plugin installs before this process resumed.
const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const pluginInstallRecords =
Object.keys(currentPluginInstallRecords).length > 0
? currentPluginInstallRecords
: parentPluginInstallRecords;
const persistedPluginIndex = await readPersistedInstalledPluginIndex();
const hasForwardedUpdateStart = Boolean(process.env[POST_CORE_UPDATE_STARTED_AT_ENV]?.trim());
const currentIndexIsAuthoritative =
Object.keys(currentPluginInstallRecords).length > 0 ||
Boolean(
persistedPluginIndex &&
hasForwardedUpdateStart &&
updateStartedAtMs !== undefined &&
persistedPluginIndex.generatedAtMs >= updateStartedAtMs,
);
const pluginInstallRecords = currentIndexIsAuthoritative
? currentPluginInstallRecords
: parentPluginInstallRecords;
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
root: params.root,
+34 -16
View File
@@ -8,42 +8,54 @@ import {
} from "./install-persistence.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
function hasValidBundledPluginConfig(params: {
type BundledPluginConfigEnablement =
| { mode: "ready" }
| { mode: "missing" }
| { mode: "invalid"; error: string };
function resolveBundledPluginConfigEnablement(params: {
bundledSource: BundledPluginSource;
existingEntry: unknown;
}): boolean {
}): BundledPluginConfigEnablement {
if (!params.bundledSource.requiresConfig) {
return true;
return { mode: "ready" };
}
if (!isRecord(params.existingEntry)) {
return false;
}
const config = params.existingEntry.config;
if (!isRecord(config)) {
return false;
const entry = isRecord(params.existingEntry) ? params.existingEntry : undefined;
if (!entry || !Object.hasOwn(entry, "config")) {
return { mode: "missing" };
}
const config = entry.config;
if (!params.bundledSource.configSchema) {
return Object.keys(config).length > 0;
return isRecord(config) && Object.keys(config).length > 0
? { mode: "ready" }
: { mode: "invalid", error: "config must be a non-empty object" };
}
return validateJsonSchemaValue({
const result = validateJsonSchemaValue({
schema: params.bundledSource.configSchema,
cacheKey: `bundled-install:${params.bundledSource.pluginId}`,
value: config,
applyDefaults: true,
}).ok;
});
return result.ok
? { mode: "ready" }
: { mode: "invalid", error: result.errors[0]?.text ?? "invalid plugin config" };
}
function prepareConfigForDisabledBundledInstall(
config: OpenClawConfig,
pluginId: string,
): OpenClawConfig {
const entries = config.plugins?.entries ?? {};
const { [pluginId]: _removedEntry, ...nextEntries } = entries;
const entry = config.plugins?.entries?.[pluginId];
const policy = isRecord(entry) ? { ...entry } : {};
delete policy.config;
return {
...config,
plugins: {
...config.plugins,
entries: nextEntries,
entries: {
...config.plugins?.entries,
[pluginId]: { ...policy, enabled: false },
},
},
};
}
@@ -58,10 +70,16 @@ export async function installBundledPluginSource(params: {
}): Promise<{ pluginId: string; warnings: string[] }> {
// Bundled plugins with required config are recorded but not enabled until config validates.
const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId];
const shouldEnable = hasValidBundledPluginConfig({
const configEnablement = resolveBundledPluginConfigEnablement({
bundledSource: params.bundledSource,
existingEntry,
});
if (configEnablement.mode === "invalid") {
throw new Error(
`Plugin "${params.bundledSource.pluginId}" has invalid configured settings: ${configEnablement.error}. Fix plugins.entries.${params.bundledSource.pluginId}.config, then rerun the install.`,
);
}
const shouldEnable = configEnablement.mode === "ready";
const configBase = shouldEnable
? params.snapshot.config
: prepareConfigForDisabledBundledInstall(params.snapshot.config, params.bundledSource.pluginId);
+117 -3
View File
@@ -698,7 +698,7 @@ describe("persistPluginInstall", () => {
onlyPluginIds: ["legacy-memory"],
});
expect(
requireMockCallArg(loadPluginManifestRegistry, "loadPluginManifestRegistry").config,
requireMockCallArg(loadPluginManifestRegistry, "loadPluginManifestRegistry", 1).config,
).toBe(enabledConfig);
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true);
expect(next.plugins?.slots?.memory).toBe("legacy-memory");
@@ -767,7 +767,7 @@ describe("persistPluginInstall", () => {
expect(buildPluginDiagnosticsReport).not.toHaveBeenCalled();
expect(
requireMockCallArg(loadPluginManifestRegistry, "loadPluginManifestRegistry").config,
requireMockCallArg(loadPluginManifestRegistry, "loadPluginManifestRegistry", 1).config,
).toBe(enabledConfig);
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true);
expect(next.plugins?.slots?.memory).toBe("memory-b");
@@ -822,11 +822,125 @@ describe("persistPluginInstall", () => {
onlyPluginIds: ["plain"],
});
expect(
requireMockCallArg(loadPluginManifestRegistry, "loadPluginManifestRegistry").config,
requireMockCallArg(loadPluginManifestRegistry, "loadPluginManifestRegistry", 1).config,
).toBe(enabledConfig);
expect(next).toEqual(enabledConfig);
});
it("installs a plugin disabled when its required configuration is missing", async () => {
const { persistPluginInstall } = await import("./install-persistence.js");
const baseConfig = {
plugins: {
allow: ["memory-core"],
deny: ["needs-config"],
entries: {
"needs-config": { hooks: { timeoutMs: 5_000 } },
},
},
} as OpenClawConfig;
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "needs-config",
manifestPath: "/tmp/needs-config/openclaw.plugin.json",
configSchema: {
type: "object",
required: ["token"],
properties: { token: { type: "string" } },
},
},
],
diagnostics: [],
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "needs-config",
install: {
source: "npm",
spec: "needs-config@1.0.0",
installPath: "/tmp/needs-config",
},
});
expect(next).toEqual({
plugins: {
allow: ["memory-core", "needs-config"],
entries: {
"needs-config": { enabled: false, hooks: { timeoutMs: 5_000 } },
},
},
});
expect(enablePluginInConfig).not.toHaveBeenCalled();
expect(applyExclusiveSlotSelection).not.toHaveBeenCalled();
expectRuntimeLogIncludes(
'Installed plugin "needs-config" without enabling it because it requires configuration first.',
);
const persistedRecords = requireMockCallArg(
writePersistedInstalledPluginIndexInstallRecords,
"writePersistedInstalledPluginIndexInstallRecords",
);
expect(persistedRecords["needs-config"]).toMatchObject({
source: "npm",
spec: "needs-config@1.0.0",
installPath: "/tmp/needs-config",
});
});
it("rejects invalid authored plugin config even for a disabled install", async () => {
const { persistPluginInstall } = await import("./install-persistence.js");
const baseConfig = {
plugins: {
entries: {
"needs-config": {
enabled: false,
config: null as never,
hooks: { timeoutMs: 5_000 },
},
},
},
} as OpenClawConfig;
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "needs-config",
manifestPath: "/tmp/needs-config/openclaw.plugin.json",
configSchema: {
type: "object",
required: ["token"],
properties: { token: { type: "string" } },
},
},
],
diagnostics: [],
});
await expect(
persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
writeOptions: installWriteOptions,
},
pluginId: "needs-config",
enable: false,
install: {
source: "npm",
spec: "needs-config@1.0.0",
installPath: "/tmp/needs-config",
},
}),
).rejects.toThrow("has invalid configured settings");
expect(enablePluginInConfig).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("can persist an install record without enabling a plugin that needs config first", async () => {
const { persistPluginInstall } = await import("./install-persistence.js");
const baseConfig = {
+91 -21
View File
@@ -23,8 +23,10 @@ import {
withoutPluginInstallRecords,
} from "./installed-plugin-index-records.js";
import type { PluginInstallUpdate } from "./installs.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { tracePluginLifecyclePhaseAsync } from "./plugin-lifecycle-trace.js";
import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
import { applySlotSelectionForPlugin } from "./slot-selection.js";
import { buildPluginSnapshotReport } from "./status.js";
import {
@@ -427,6 +429,56 @@ function resolveReplacedManagedInstallRemoval(params: {
return plan.directoryRemoval;
}
function prepareConfigForDisabledInstall(config: OpenClawConfig, pluginId: string): OpenClawConfig {
const entry = config.plugins?.entries?.[pluginId];
const policy = isRecord(entry) ? { ...entry } : {};
delete policy.config;
return {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[pluginId]: { ...policy, enabled: false },
},
},
};
}
type PluginConfigEnablement =
| { mode: "ready" }
| { mode: "missing" }
| { mode: "invalid"; error: string };
function resolvePluginConfigEnablement(params: {
config: OpenClawConfig;
pluginId: string;
installRecords: Record<string, PluginInstallRecord>;
}): PluginConfigEnablement {
const manifest = loadPluginManifestRegistry({
config: params.config,
installRecords: params.installRecords,
}).plugins.find((plugin) => plugin.id === params.pluginId);
if (!manifest?.configSchema) {
return { mode: "ready" };
}
const entry = params.config.plugins?.entries?.[params.pluginId];
const hasConfig = isRecord(entry) && Object.hasOwn(entry, "config");
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: manifest.schemaCacheKey ?? manifest.manifestPath,
value: hasConfig ? entry.config : {},
applyDefaults: true,
});
if (result.ok) {
return { mode: "ready" };
}
if (!hasConfig) {
return { mode: "missing" };
}
return { mode: "invalid", error: result.errors[0]?.text ?? "invalid plugin config" };
}
export async function persistPluginInstall(params: {
snapshot: ConfigSnapshotForInstallPersist;
pluginId: string;
@@ -438,19 +490,6 @@ export async function persistPluginInstall(params: {
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
const runtime = params.runtime ?? defaultRuntime;
const installConfig =
params.enable === false
? params.snapshot.config
: removeInstalledPluginFromDenylist(
addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId),
params.pluginId,
);
let next =
params.enable === false
? installConfig
: enablePluginInConfig(installConfig, params.pluginId, {
updateChannelConfig: false,
}).config;
const installRecords = await tracePluginLifecyclePhaseAsync(
"install records load",
() => loadInstalledPluginIndexInstallRecords(),
@@ -466,14 +505,40 @@ export async function persistPluginInstall(params: {
pluginId: params.pluginId,
...params.install,
});
const slotResult =
const configEnablement = resolvePluginConfigEnablement({
config: params.snapshot.config,
pluginId: params.pluginId,
installRecords: nextInstallRecords,
});
if (configEnablement.mode === "invalid") {
throw new Error(
`Plugin "${params.pluginId}" has invalid configured settings: ${configEnablement.error}. Fix plugins.entries.${params.pluginId}.config, then rerun the install.`,
);
}
const shouldEnable = params.enable !== false && configEnablement.mode === "ready";
const configBase =
params.enable === false || configEnablement.mode === "ready"
? params.snapshot.config
: prepareConfigForDisabledInstall(params.snapshot.config, params.pluginId);
const installConfig =
params.enable === false
? { config: next, warnings: [] }
: await tracePluginLifecyclePhaseAsync(
"slot selection",
async () => applySlotSelectionForPlugin(next, params.pluginId),
{ command: "install", pluginId: params.pluginId },
? configBase
: removeInstalledPluginFromDenylist(
addInstalledPluginToAllowlist(configBase, params.pluginId),
params.pluginId,
);
let next = shouldEnable
? enablePluginInConfig(installConfig, params.pluginId, {
updateChannelConfig: false,
}).config
: installConfig;
const slotResult = shouldEnable
? await tracePluginLifecyclePhaseAsync(
"slot selection",
async () => applySlotSelectionForPlugin(next, params.pluginId),
{ command: "install", pluginId: params.pluginId },
)
: { config: next, warnings: [] };
next = withoutPluginInstallRecords(slotResult.config);
await tracePluginLifecyclePhaseAsync(
"config mutation",
@@ -518,8 +583,13 @@ export async function persistPluginInstall(params: {
},
});
logSlotWarnings(slotResult.warnings, runtime);
if (params.warningMessage) {
runtime.log(theme.warn(params.warningMessage));
const configWarning =
params.enable !== false && configEnablement.mode === "missing"
? `Installed plugin "${params.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.pluginId}\`.`
: undefined;
const warningMessage = [params.warningMessage, configWarning].filter(Boolean).join("\n");
if (warningMessage) {
runtime.log(theme.warn(warningMessage));
}
runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`);
logShadowedNpmInstallWarning({
+54 -15
View File
@@ -14,7 +14,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createAsyncLock } from "../infra/json-files.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import type { RuntimeEnv } from "../runtime.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
@@ -54,6 +53,7 @@ import {
type HostedOfficialExternalPluginCatalogLoadResult,
type OfficialExternalPluginCatalogEntry,
} from "./official-external-plugin-catalog.js";
import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js";
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { resolveManifestProviderAuthChoices } from "./provider-auth-choices.js";
import { listRecommendedToolInstalls } from "./recommended-tool-installs.js";
@@ -65,6 +65,8 @@ import {
applyPluginUninstallDirectoryRemoval,
formatUninstallActionLabels,
planPluginUninstall,
pluginUninstallTargetExists,
prepareConfigForPendingPluginDirectoryRemoval,
} from "./uninstall.js";
type ManagedPluginCatalogEntry = {
@@ -758,8 +760,6 @@ export async function listManagedPlugins(params: {
};
}
const withManagedPluginMutationLock = createAsyncLock();
function assertValidConfigSnapshot(
prepared: Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>>,
): ConfigSnapshotForInstallPersist {
@@ -1132,8 +1132,8 @@ export async function installManagedPlugin(params: {
request: ManagedPluginInstallRequest;
env?: NodeJS.ProcessEnv;
}): Promise<{ plugin: ManagedPluginCatalogEntry; warnings?: string[] }> {
return await withManagedPluginMutationLock(async () => {
const env = params.env ?? process.env;
const env = params.env ?? process.env;
return await withPluginLifecycleLease({ env }, async () => {
const snapshot = await readPluginMutationSnapshot(env);
const officialCatalog = await loadOfficialCatalog();
const warnings: string[] = [];
@@ -1181,8 +1181,8 @@ export async function setManagedPluginEnabled(params: {
changedPaths: string[];
warnings?: string[];
}> {
return await withManagedPluginMutationLock(async () => {
const env = params.env ?? process.env;
const env = params.env ?? process.env;
return await withPluginLifecycleLease({ env }, async () => {
const snapshot = await readPluginMutationSnapshot(env);
const metadata = loadPluginMetadataSnapshot({ config: snapshot.config, env });
const pluginId = metadata.normalizePluginId(params.pluginId.trim());
@@ -1247,8 +1247,8 @@ export async function uninstallManagedPlugin(params: {
pluginId: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ pluginId: string; removed: string[]; warnings?: string[] }> {
return await withManagedPluginMutationLock(async () => {
const env = params.env ?? process.env;
const env = params.env ?? process.env;
return await withPluginLifecycleLease({ env }, async () => {
const snapshot = await readPluginMutationSnapshot(env);
const installRecords = await loadInstalledPluginIndexInstallRecords();
// Mirror the CLI uninstall flow: plan against config carrying install records
@@ -1267,15 +1267,55 @@ export async function uninstallManagedPlugin(params: {
// planPluginUninstall keeps its plugin-id fallback for channel config keys.
const channelIds = manifest && manifest.channels.length > 0 ? manifest.channels : undefined;
const extensionsDir = resolveDefaultPluginExtensionsDir(env);
const plan = planPluginUninstall({
const initialPlan = planPluginUninstall({
config: configWithRecords,
pluginId,
...(channelIds ? { channelIds } : {}),
deleteFiles: true,
extensionsDir,
});
if (!plan.ok) {
throw new ManagedPluginLifecycleError(plan.error);
if (!initialPlan.ok) {
throw new ManagedPluginLifecycleError(initialPlan.error);
}
let plan = initialPlan;
let finalSnapshot = snapshot;
let directoryResult = { directoryRemoved: false, warnings: [] as string[] };
if (plan.directoryRemoval) {
const disabledConfig = prepareConfigForPendingPluginDirectoryRemoval(
snapshot.config,
pluginId,
);
await replaceConfigFile({
nextConfig: disabledConfig,
baseHash: snapshot.baseHash,
writeOptions: {
...snapshot.writeOptions,
afterWrite: { mode: "auto" },
},
});
directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
if (pluginUninstallTargetExists(plan.directoryRemoval.target)) {
throw new ManagedPluginLifecycleError(
`Failed to remove plugin directory ${plan.directoryRemoval.target}; the plugin remains disabled and tracked so uninstall can be retried.`,
{ kind: "unavailable" },
);
}
finalSnapshot = await readPluginMutationSnapshot(env);
const refreshedConfigWithRecords = withPluginInstallRecords(
finalSnapshot.config,
installRecords,
);
const refreshedPlan = planPluginUninstall({
config: refreshedConfigWithRecords,
pluginId,
...(channelIds ? { channelIds } : {}),
deleteFiles: true,
extensionsDir,
});
if (!refreshedPlan.ok) {
throw new ManagedPluginLifecycleError(refreshedPlan.error);
}
plan = refreshedPlan;
}
const nextConfig = withoutPluginInstallRecords(plan.config);
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
@@ -1283,10 +1323,9 @@ export async function uninstallManagedPlugin(params: {
previousInstallRecords: installRecords,
nextInstallRecords,
nextConfig,
baseHash: snapshot.baseHash,
writeOptions: snapshot.writeOptions,
baseHash: finalSnapshot.baseHash,
writeOptions: finalSnapshot.writeOptions,
});
const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
const warnings = [
...collectClawPluginUninstallWarnings({
pluginId,
+180
View File
@@ -0,0 +1,180 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js";
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
async function waitForPath(filePath: string): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
await fs.access(filePath);
return;
} catch {
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
}
throw new Error(`timed out waiting for ${filePath}`);
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function runLeaseChild(scriptPath: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk) => (output += chunk));
child.stderr.on("data", (chunk) => (output += chunk));
child.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`lease child exited ${code}: ${output}`));
}
});
});
}
describe("plugin lifecycle lease", () => {
it("serializes lifecycle work sharing one state directory", async () => {
await withOpenClawTestState({ label: "plugin-lifecycle-lease" }, async (state) => {
const firstEntered = deferred();
const releaseFirst = deferred();
const events: string[] = [];
const first = withPluginLifecycleLease(
{ env: state.env, leaseMs: 1_000, waitMs: 3_000 },
async () => {
events.push("first-enter");
firstEntered.resolve();
await releaseFirst.promise;
events.push("first-exit");
},
);
await firstEntered.promise;
const second = withPluginLifecycleLease(
{ env: state.env, leaseMs: 1_000, waitMs: 3_000 },
async () => {
events.push("second-enter");
},
);
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(events).toEqual(["first-enter"]);
releaseFirst.resolve();
await Promise.all([first, second]);
expect(events).toEqual(["first-enter", "first-exit", "second-enter"]);
});
});
it("serializes lifecycle work across processes", async () => {
await withOpenClawTestState({ label: "plugin-lifecycle-processes" }, async (state) => {
const firstMarker = state.path("first-entered");
const releaseMarker = state.path("release-first");
const secondMarker = state.path("second-entered");
const secondReady = state.path("second-ready");
const secondResult = state.path("second-result");
const leaseModuleUrl = pathToFileURL(
path.resolve("src/plugins/plugin-lifecycle-lease.ts"),
).href;
const childScript = await state.writeText(
"lease-child.mts",
`
import fs from "node:fs/promises";
import { withPluginLifecycleLease } from ${JSON.stringify(leaseModuleUrl)};
const [role, stateDir, firstMarker, releaseMarker, secondMarker, secondReady, secondResult] = process.argv.slice(2);
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
if (role === "second") {
await fs.writeFile(secondReady, "ready");
try {
await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 0 }, async () => {
await fs.writeFile(secondMarker, "entered");
});
await fs.writeFile(secondResult, "acquired");
} catch (error) {
await fs.writeFile(secondResult, error?.code ?? String(error));
}
} else {
await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 5_000 }, async () => {
await fs.writeFile(firstMarker, "entered");
while (true) {
try {
await fs.access(releaseMarker);
break;
} catch {
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
}
});
}
`,
);
const childArgs = [
state.stateDir,
firstMarker,
releaseMarker,
secondMarker,
secondReady,
secondResult,
];
const first = runLeaseChild(childScript, ["first", ...childArgs]);
await waitForPath(firstMarker);
const second = runLeaseChild(childScript, ["second", ...childArgs]);
await waitForPath(secondReady);
await waitForPath(secondResult);
let assertionError: unknown;
try {
await expect(fs.readFile(secondResult, "utf8")).resolves.toBe(
"OPENCLAW_STATE_LEASE_TIMEOUT",
);
await expect(fs.access(secondMarker)).rejects.toMatchObject({ code: "ENOENT" });
} catch (error) {
assertionError = error;
} finally {
await fs.writeFile(releaseMarker, "release");
}
await Promise.all([first, second]);
if (assertionError) {
throw assertionError instanceof Error
? assertionError
: new Error("cross-process lease assertion failed", { cause: assertionError });
}
});
});
it("reuses the active lease for nested lifecycle work", async () => {
await withOpenClawTestState({ label: "plugin-lifecycle-reentrant" }, async (state) => {
const events: string[] = [];
await withPluginLifecycleLease({ env: state.env, leaseMs: 1_000, waitMs: 0 }, async () => {
events.push("outer");
await withPluginLifecycleLease({ env: state.env, leaseMs: 1_000, waitMs: 0 }, async () => {
events.push("inner");
});
});
expect(events).toEqual(["outer", "inner"]);
});
});
});
+76
View File
@@ -0,0 +1,76 @@
import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import {
OpenClawStateLeaseError,
withOpenClawStateLease,
type OpenClawStateLeaseContext,
} from "../state/openclaw-state-lease.js";
const PLUGIN_LIFECYCLE_LEASE_SCOPE = "core:plugin-lifecycle";
const PLUGIN_LIFECYCLE_LEASE_KEY = "global";
const DEFAULT_PLUGIN_LIFECYCLE_LEASE_MS = 5 * 60_000;
const DEFAULT_PLUGIN_LIFECYCLE_WAIT_MS = 10 * 60_000;
type ActivePluginLifecycleLease = {
databasePath: string;
lease: OpenClawStateLeaseContext;
};
type PluginLifecycleLeaseOptions = {
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
leaseMs?: number;
waitMs?: number;
};
const activePluginLifecycleLease = new AsyncLocalStorage<ActivePluginLifecycleLease>();
function resolveLifecycleLeaseEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
const requested = env ?? process.env;
if (!process.env.VITEST || requested.VITEST || requested.OPENCLAW_STATE_DIR) {
return requested;
}
return {
...requested,
VITEST: process.env.VITEST,
VITEST_WORKER_ID: process.env.VITEST_WORKER_ID,
VITEST_POOL_ID: process.env.VITEST_POOL_ID,
};
}
/** Serialize plugin artifact, install-index, and config mutations across processes. */
export async function withPluginLifecycleLease<T>(
options: PluginLifecycleLeaseOptions,
run: (lease: OpenClawStateLeaseContext) => Promise<T>,
): Promise<T> {
const env = resolveLifecycleLeaseEnv(options.env);
const databasePath = path.resolve(resolveOpenClawStateSqlitePath(env));
const active = activePluginLifecycleLease.getStore();
if (active) {
if (active.databasePath !== databasePath) {
throw new OpenClawStateLeaseError(
"nested plugin lifecycle lease cannot switch the shared state database",
{ code: "OPENCLAW_STATE_LEASE_INVALID_INPUT" },
);
}
options.signal?.throwIfAborted();
active.lease.assertOwned();
return await run(active.lease);
}
return await withOpenClawStateLease(
{
scope: PLUGIN_LIFECYCLE_LEASE_SCOPE,
key: PLUGIN_LIFECYCLE_LEASE_KEY,
database: { scope: "shared", options: { env } },
leaseMs: options.leaseMs ?? DEFAULT_PLUGIN_LIFECYCLE_LEASE_MS,
waitMs: options.waitMs ?? DEFAULT_PLUGIN_LIFECYCLE_WAIT_MS,
...(options.signal ? { signal: options.signal } : {}),
leaseLabel: "plugin lifecycle lease",
operationLabel: "plugins.lifecycle.lease",
},
async (lease) =>
await activePluginLifecycleLease.run({ databasePath, lease }, async () => await run(lease)),
);
}
@@ -0,0 +1,22 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { applyPluginUninstallDirectoryRemoval } from "./uninstall.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("plugin uninstall directory removal", () => {
it("removes a dangling managed-target symlink", async () => {
const root = tempDirs.make("openclaw-plugin-uninstall-");
const target = path.join(root, "plugin");
await fs.symlink(path.join(root, "missing-target"), target, "dir");
await expect(fs.lstat(target)).resolves.toBeDefined();
await expect(applyPluginUninstallDirectoryRemoval({ target })).resolves.toEqual({
directoryRemoved: true,
warnings: [],
});
await expect(fs.lstat(target)).rejects.toMatchObject({ code: "ENOENT" });
});
});
+31 -6
View File
@@ -1,5 +1,5 @@
// Removes installed plugins and updates plugin index records.
import { realpathSync } from "node:fs";
import { lstatSync, realpathSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -82,6 +82,26 @@ export function formatUninstallActionLabels(actions: UninstallActions): string[]
);
}
/** 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: Omit<UninstallActions, "directory">): boolean {
return Object.values(actions).some(Boolean);
}
@@ -595,6 +615,15 @@ export function planPluginUninstall(params: UninstallPluginParams): PluginUninst
};
}
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[] }> {
@@ -602,11 +631,7 @@ export async function applyPluginUninstallDirectoryRemoval(
return { directoryRemoved: false, warnings: [] };
}
const existed =
(await fs
.access(removal.target)
.then(() => true)
.catch(() => false)) ?? false;
const existed = pluginUninstallTargetExists(removal.target);
const warnings: string[] = [];
if (!existed && removal.cleanup?.kind !== "npm") {
return { directoryRemoved: false, warnings };