From 40e5d9adc7690e8127f5c06799b3a57f16a1e466 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 07:14:21 +0100 Subject: [PATCH] fix(plugins): update external plugins in recorded root --- src/plugins/clawhub.ts | 2 + src/plugins/marketplace.ts | 2 + src/plugins/update.test.ts | 79 +++++++++++++++++++++++++++++++++++++- src/plugins/update.ts | 24 ++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 0a6ad7cc07d..3fe8bfba624 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -746,6 +746,7 @@ export async function installPluginFromClawHub( token?: string; logger?: PluginInstallLogger; mode?: "install" | "update"; + extensionsDir?: string; dryRun?: boolean; expectedPluginId?: string; }, @@ -862,6 +863,7 @@ export async function installPluginFromClawHub( dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, logger: params.logger, mode: params.mode, + extensionsDir: params.extensionsDir, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, }); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index ac094d5538c..7442aedb73c 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -1109,6 +1109,7 @@ export async function installPluginFromMarketplace( logger?: MarketplaceLogger; timeoutMs?: number; mode?: "install" | "update"; + extensionsDir?: string; dryRun?: boolean; expectedPluginId?: string; }, @@ -1154,6 +1155,7 @@ export async function installPluginFromMarketplace( path: resolved.path, logger: params.logger, mode: params.mode, + extensionsDir: params.extensionsDir, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, }); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 5514567655c..adc2af7be2c 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -21,7 +21,8 @@ const tempDirs: string[] = []; vi.mock("./install.js", () => ({ installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpecMock(...args), - resolvePluginInstallDir: (pluginId: string) => `/tmp/${pluginId}`, + resolvePluginInstallDir: (pluginId: string, extensionsDir = "/tmp") => + `${extensionsDir}/${pluginId}`, PLUGIN_INSTALL_ERROR_CODE: { NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", }, @@ -920,6 +921,82 @@ describe("updateNpmInstalledPlugins", () => { }), ); }); + + it("reuses the recorded managed extensions root when updating external plugins", async () => { + const installPath = "/var/openclaw/extensions/demo"; + const extensionsDir = "/var/openclaw/extensions"; + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "demo", + targetDir: installPath, + version: "1.2.0", + }), + ); + installPluginFromClawHubMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: installPath, + version: "1.2.0", + extensions: ["index.ts"], + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + integrity: "sha256-next", + resolvedAt: "2026-03-22T00:00:00.000Z", + }, + }); + installPluginFromMarketplaceMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: installPath, + version: "1.2.0", + extensions: ["index.ts"], + marketplaceSource: "acme/plugins", + marketplacePlugin: "demo", + }); + + await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "demo", + spec: "@acme/demo", + installPath, + }), + pluginIds: ["demo"], + }); + await updateNpmInstalledPlugins({ + config: createClawHubInstallConfig({ + pluginId: "demo", + installPath, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }), + pluginIds: ["demo"], + }); + await updateNpmInstalledPlugins({ + config: createMarketplaceInstallConfig({ + pluginId: "demo", + installPath, + marketplaceSource: "acme/plugins", + marketplacePlugin: "demo", + }), + pluginIds: ["demo"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ extensionsDir }), + ); + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ extensionsDir }), + ); + expect(installPluginFromMarketplaceMock).toHaveBeenCalledWith( + expect.objectContaining({ extensionsDir }), + ); + }); }); describe("syncPluginsForUpdateChannel", () => { diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 5602c488b00..48da2a3bea1 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { NpmSpecResolution } from "../infra/install-source-utils.js"; @@ -155,6 +156,19 @@ function pathsEqual( return resolveUserPath(left, env) === resolveUserPath(right, env); } +function resolveRecordedExtensionsDir(params: { + pluginId: string; + installPath: string; +}): string | undefined { + const parentDir = path.dirname(params.installPath); + try { + const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir); + return canonicalInstallPath === params.installPath ? parentDir : undefined; + } catch { + return undefined; + } +} + function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = process.env) { let paths = [...existing]; const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry, env))); @@ -535,6 +549,10 @@ export async function updateNpmInstalledPlugins(params: { continue; } const currentVersion = await readInstalledPackageVersion(installPath); + const extensionsDir = resolveRecordedExtensionsDir({ + pluginId, + installPath, + }); if (!params.dryRun && record.source === "npm" && currentVersion) { const metadataResult = await resolveNpmSpecMetadata({ spec: effectiveSpec! }); @@ -573,6 +591,7 @@ export async function updateNpmInstalledPlugins(params: { ? await installPluginFromNpmSpec({ spec: effectiveSpec!, mode: "update", + extensionsDir, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -590,6 +609,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, baseUrl: record.clawhubUrl, mode: "update", + extensionsDir, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -599,6 +619,7 @@ export async function updateNpmInstalledPlugins(params: { marketplace: record.marketplaceSource!, plugin: record.marketplacePlugin!, mode: "update", + extensionsDir, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -674,6 +695,7 @@ export async function updateNpmInstalledPlugins(params: { ? await installPluginFromNpmSpec({ spec: effectiveSpec!, mode: "update", + extensionsDir, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, expectedIntegrity, @@ -690,6 +712,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, baseUrl: record.clawhubUrl, mode: "update", + extensionsDir, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, @@ -698,6 +721,7 @@ export async function updateNpmInstalledPlugins(params: { marketplace: record.marketplaceSource!, plugin: record.marketplacePlugin!, mode: "update", + extensionsDir, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger,