diff --git a/src/cli/plugins-uninstall-selection.test.ts b/src/cli/plugins-uninstall-selection.test.ts index 66a17216158f..3804196b710b 100644 --- a/src/cli/plugins-uninstall-selection.test.ts +++ b/src/cli/plugins-uninstall-selection.test.ts @@ -5,6 +5,11 @@ import { resolvePluginUninstallId } from "./plugins-uninstall-selection.js"; describe("resolvePluginUninstallId", () => { it("accepts the recorded ClawHub spec as an uninstall target", () => { + const plugin = { + id: "linkmind-context", + name: "linkmind-context", + channelIds: ["linkmind-channel"], + }; const result = resolvePluginUninstallId({ rawId: "clawhub:linkmind-context", config: { @@ -21,13 +26,19 @@ describe("resolvePluginUninstallId", () => { }, }, } as OpenClawConfig, - plugins: [{ id: "linkmind-context", name: "linkmind-context" }], + plugins: [plugin], }); expect(result.pluginId).toBe("linkmind-context"); + expect(result.plugin).toBe(plugin); }); it("accepts a versionless ClawHub spec when the install was pinned", () => { + const plugin = { + id: "linkmind-context", + name: "linkmind-context", + channelIds: ["linkmind-channel"], + }; const result = resolvePluginUninstallId({ rawId: "clawhub:linkmind-context", config: { @@ -43,9 +54,10 @@ describe("resolvePluginUninstallId", () => { }, }, } as OpenClawConfig, - plugins: [{ id: "linkmind-context", name: "linkmind-context" }], + plugins: [plugin], }); expect(result.pluginId).toBe("linkmind-context"); + expect(result.plugin).toBe(plugin); }); }); diff --git a/src/cli/plugins-uninstall-selection.ts b/src/cli/plugins-uninstall-selection.ts index c4c75670795c..2303988a284e 100644 --- a/src/cli/plugins-uninstall-selection.ts +++ b/src/cli/plugins-uninstall-selection.ts @@ -12,6 +12,10 @@ export function resolvePluginUninstallId< plugins: TPlugin[]; }): { pluginId: string; plugin?: TPlugin } { const rawId = params.rawId.trim(); + const resolveInstalledPlugin = (pluginId: string) => { + const plugin = params.plugins.find((entry) => entry.id === pluginId); + return plugin ? { pluginId, plugin } : { pluginId }; + }; const plugin = params.plugins.find((entry) => entry.id === rawId || entry.name === rawId); if (plugin) { return { pluginId: plugin.id, plugin }; @@ -24,7 +28,7 @@ export function resolvePluginUninstallId< install.resolvedName === rawId || install.marketplacePlugin === rawId ) { - return { pluginId }; + return resolveInstalledPlugin(pluginId); } } @@ -36,7 +40,7 @@ export function resolvePluginUninstallId< parseClawHubPluginSpec(install.spec ?? "")?.name ?? parseClawHubPluginSpec(install.resolvedSpec ?? "")?.name; if (installedClawHubName === requestedClawHub.name) { - return { pluginId }; + return resolveInstalledPlugin(pluginId); } } } diff --git a/src/state/claw-package-adoption.test.ts b/src/state/claw-package-adoption.test.ts index aa2242da1994..41c63d39a540 100644 --- a/src/state/claw-package-adoption.test.ts +++ b/src/state/claw-package-adoption.test.ts @@ -10,7 +10,10 @@ import { } from "../claws/provenance.js"; import type { ClawAddPlan } from "../claws/types.js"; import { markClawPackageIndependentlyOwned } from "./claw-package-adoption.js"; -import { acquireClawPackageLifecycleLease } from "./claw-package-lifecycle-lease.js"; +import { + acquireClawPackageLifecycleLease, + withClawPackageLifecycleLease, +} from "./claw-package-lifecycle-lease.js"; import { closeOpenClawStateDatabaseForTest } from "./openclaw-state-db.js"; afterEach(() => closeOpenClawStateDatabaseForTest()); @@ -237,6 +240,34 @@ describe("Claw package independent adoption", () => { directLease?.release(); }); + it("releases a package lease when process exit bypasses async cleanup", async () => { + const env = { OPENCLAW_STATE_DIR: tempDirs.make("claw-exit-lease-") }; + const artifact = { kind: "plugin", source: "clawhub", ref: "@acme/audit" } as const; + const existingExitListeners = new Set(process.listeners("exit")); + + await expect( + withClawPackageLifecycleLease( + artifact, + async () => { + const exitCleanup = process + .listeners("exit") + .find((listener) => !existingExitListeners.has(listener)); + expect(exitCleanup).toBeTypeOf("function"); + exitCleanup?.(1); + throw new Error("simulated process exit"); + }, + { env, required: true }, + ), + ).rejects.toThrow("simulated process exit"); + + expect( + process.listeners("exit").filter((listener) => !existingExitListeners.has(listener)), + ).toEqual([]); + const nextLease = acquireClawPackageLifecycleLease(artifact, { env, required: true }); + expect(nextLease).not.toBeNull(); + nextLease?.release(); + }); + it("fails open only for optional direct leases when lifecycle state is unavailable", () => { const invalidDatabasePath = tempDirs.make("claw-invalid-db-path-"); const artifact = { kind: "plugin", source: "clawhub", ref: "@acme/audit" } as const; diff --git a/src/state/claw-package-lifecycle-lease.ts b/src/state/claw-package-lifecycle-lease.ts index 40f8fcac25e2..01c0b38e6232 100644 --- a/src/state/claw-package-lifecycle-lease.ts +++ b/src/state/claw-package-lifecycle-lease.ts @@ -205,11 +205,22 @@ export async function withClawPackageLifecycleLease( return await operation(); } const maintained = maintainClawPackageLifecycleLease(lease); + // CLI failures call process.exit(), which skips async finally blocks. Release + // synchronously on exit so the next package command is not blocked until TTL. + const releaseOnExit = () => { + try { + maintained.release(); + } catch { + // Expiry recovers a lease whose exit cleanup loses a database race. + } + }; + process.once("exit", releaseOnExit); try { const result = await operation(); maintained.assertCurrent(); return result; } finally { + process.removeListener("exit", releaseOnExit); try { maintained.release(); } catch {