mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
67ef07863f
commit
a12e0f26ee
+3
-1
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user