diff --git a/CHANGELOG.md b/CHANGELOG.md index d165d3c116b..b89f8d1ae74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Control UI/Gateway: avoid full session-list reloads for locally applied message-phase session updates, carry known session keys through transcript-file update events, and defer media provider listing when explicit generation model config is present. Refs #76236, #76203, #76188, #76107, and #76166. Thanks @BunsDev. +- Install/update: prune the obsolete `plugin-runtime-deps` state directory during packaged postinstall so upgrades from pre-2026.5.2 releases reclaim old bundled-plugin dependency caches without touching external plugin installs. - Gateway: keep directly requested plugin tools invokable under restrictive tool profiles while preserving explicit deny lists and the HTTP safety deny list, preventing catalog/invoke mismatches that surface as "Tool not available". Thanks @BunsDev. - Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates. - Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc. diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 6dee0164a4a..8f18ed61dc2 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -18,8 +18,16 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, posix, relative } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { + basename, + dirname, + isAbsolute, + join, + posix, + relative, + resolve as pathResolve, +} from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -27,6 +35,7 @@ const DEFAULT_PACKAGE_ROOT = join(__dirname, ".."); const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL"; const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION"; const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json"; +const LEGACY_PLUGIN_RUNTIME_DEPS_DIR = "plugin-runtime-deps"; const BAILEYS_MEDIA_FILE = join( "node_modules", "@whiskeysockets", @@ -107,6 +116,30 @@ function normalizeRelativePath(filePath) { return filePath.replace(/\\/g, "/"); } +function resolvePostinstallOsHomeDir(env, getHomedir = homedir) { + return env?.HOME?.trim() || env?.USERPROFILE?.trim() || getHomedir(); +} + +function resolvePostinstallTildePath(input, homeDir) { + if (input === "~") { + return homeDir; + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return join(homeDir, input.slice(2)); + } + return input; +} + +function resolvePostinstallOpenClawHomeDir(env, getHomedir = homedir) { + const osHome = resolvePostinstallOsHomeDir(env, getHomedir); + const override = env?.OPENCLAW_HOME?.trim(); + return override ? pathResolve(resolvePostinstallTildePath(override, osHome)) : osHome; +} + +function resolvePostinstallUserPath(input, openClawHome) { + return pathResolve(resolvePostinstallTildePath(input, openClawHome)); +} + function readInstalledDistInventory(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; @@ -298,6 +331,75 @@ function pruneLegacyInstalledPluginDependencyDirs(params) { return removed; } +function splitPostinstallPathList(value) { + return value + ? value + .split(pathDelimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + : []; +} + +const pathDelimiter = process.platform === "win32" ? ";" : ":"; + +export function collectLegacyPluginRuntimeDepsStateRoots(params = {}) { + const env = params.env ?? process.env; + const getHomedir = params.homedir ?? homedir; + const openClawHome = resolvePostinstallOpenClawHomeDir(env, getHomedir); + const stateRoots = []; + const addStateRoot = (root) => { + if (root) { + stateRoots.push(join(root, LEGACY_PLUGIN_RUNTIME_DEPS_DIR)); + } + }; + + const stateOverride = env?.OPENCLAW_STATE_DIR?.trim(); + if (stateOverride) { + addStateRoot(resolvePostinstallUserPath(stateOverride, openClawHome)); + } + const configPath = env?.OPENCLAW_CONFIG_PATH?.trim(); + if (configPath) { + addStateRoot(dirname(resolvePostinstallUserPath(configPath, openClawHome))); + } + addStateRoot(join(openClawHome, ".openclaw")); + addStateRoot(join(openClawHome, ".clawdbot")); + + for (const entry of splitPostinstallPathList(env?.STATE_DIRECTORY)) { + addStateRoot(resolvePostinstallUserPath(entry, openClawHome)); + } + + return [...new Set(stateRoots.map((root) => pathResolve(root)))].toSorted((left, right) => + left.localeCompare(right), + ); +} + +export function pruneLegacyPluginRuntimeDepsState(params = {}) { + const pathExists = params.existsSync ?? existsSync; + const removePath = params.rmSync ?? rmSync; + const log = params.log ?? console; + const removed = []; + + for (const root of collectLegacyPluginRuntimeDepsStateRoots(params)) { + if (!pathExists(root)) { + continue; + } + try { + removePath(root, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 }); + removed.push(root); + } catch (error) { + log.warn?.( + `[postinstall] could not prune legacy plugin runtime deps ${root}: ${String(error)}`, + ); + } + } + + if (removed.length > 0) { + log.log?.(`[postinstall] pruned legacy plugin runtime deps: ${removed.join(", ")}`); + } + + return removed; +} + const JS_DIST_FILE_RE = /^dist\/.*\.(?:cjs|js|mjs)$/u; function stripSpecifierSuffix(value) { @@ -828,6 +930,13 @@ export function runBundledPluginPostinstall(params = {}) { }); return; } + pruneLegacyPluginRuntimeDepsState({ + env, + existsSync: pathExists, + rmSync: params.rmSync, + log, + homedir: params.homedir, + }); pruneInstalledPackageDist({ packageRoot, existsSync: pathExists, diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index d13c14d849d..a7abd0648a2 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -4,10 +4,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { + collectLegacyPluginRuntimeDepsStateRoots, isSourceCheckoutRoot, isDirectPostinstallInvocation, pruneOpenClawCompileCache, pruneInstalledPackageDist, + pruneLegacyPluginRuntimeDepsState, pruneBundledPluginSourceNodeModules, runBundledPluginPostinstall, runPluginRegistryPostinstallMigration, @@ -208,6 +210,25 @@ describe("bundled plugin postinstall", () => { ); }); + it("does not prune user-state legacy runtime deps during source-checkout postinstall", async () => { + const packageRoot = await createTempDirAsync("openclaw-source-checkout-state-skip-"); + const home = await createTempDirAsync("openclaw-source-checkout-home-"); + const legacyRuntimeRoot = path.join(home, ".openclaw", "plugin-runtime-deps"); + await fs.mkdir(path.join(packageRoot, ".git"), { recursive: true }); + await fs.mkdir(path.join(packageRoot, "src"), { recursive: true }); + await fs.mkdir(path.join(packageRoot, "extensions"), { recursive: true }); + await fs.mkdir(legacyRuntimeRoot, { recursive: true }); + await fs.writeFile(path.join(legacyRuntimeRoot, "package.json"), "{}\n"); + + runBundledPluginPostinstall({ + env: { HOME: home }, + packageRoot, + log: { log: vi.fn(), warn: vi.fn() }, + }); + + await expect(fs.stat(legacyRuntimeRoot)).resolves.toBeTruthy(); + }); + it("honors disable env before source-checkout pruning", async () => { const packageRoot = await createTempDirAsync("openclaw-source-checkout-disabled-"); const extensionsDir = path.join(packageRoot, "extensions"); @@ -373,6 +394,103 @@ describe("bundled plugin postinstall", () => { await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("prunes legacy plugin runtime deps state during packaged postinstall", async () => { + const packageRoot = await createTempDirAsync("openclaw-packaged-state-cleanup-"); + const home = await createTempDirAsync("openclaw-packaged-home-"); + const stateOverride = path.join(home, "custom-state"); + const systemState = path.join(home, "system-state"); + const defaultLegacyRoot = path.join(home, ".openclaw", "plugin-runtime-deps"); + const oldBrandLegacyRoot = path.join(home, ".clawdbot", "plugin-runtime-deps"); + const overrideLegacyRoot = path.join(stateOverride, "plugin-runtime-deps"); + const systemLegacyRoot = path.join(systemState, "plugin-runtime-deps"); + const thirdPartyNodeModules = path.join( + home, + ".openclaw", + "extensions", + "lossless-claw", + "node_modules", + ); + const currentFile = path.join(packageRoot, "dist", "entry.js"); + + await fs.mkdir(path.dirname(currentFile), { recursive: true }); + await fs.writeFile(currentFile, "export {};\n"); + await writePackageDistInventory(packageRoot); + for (const root of [ + defaultLegacyRoot, + oldBrandLegacyRoot, + overrideLegacyRoot, + systemLegacyRoot, + thirdPartyNodeModules, + ]) { + await fs.mkdir(root, { recursive: true }); + await fs.writeFile(path.join(root, "package.json"), "{}\n"); + } + + const log = { log: vi.fn(), warn: vi.fn() }; + runBundledPluginPostinstall({ + env: { + HOME: home, + OPENCLAW_STATE_DIR: stateOverride, + STATE_DIRECTORY: systemState, + }, + packageRoot, + log, + }); + + await expect(fs.stat(defaultLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(oldBrandLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(overrideLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(systemLegacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(thirdPartyNodeModules)).resolves.toBeTruthy(); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.log).toHaveBeenCalledWith( + expect.stringContaining("[postinstall] pruned legacy plugin runtime deps:"), + ); + }); + + it("keeps legacy plugin runtime deps cleanup non-fatal", () => { + const warn = vi.fn(); + + expect(() => + pruneLegacyPluginRuntimeDepsState({ + env: { HOME: "/home/alice" }, + existsSync: vi.fn(() => true), + rmSync: vi.fn(() => { + throw new Error("locked"); + }), + log: { log: vi.fn(), warn }, + homedir: () => "/home/alice", + }), + ).not.toThrow(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "[postinstall] could not prune legacy plugin runtime deps /home/alice/.openclaw/plugin-runtime-deps: Error: locked", + ), + ); + }); + + it("resolves legacy plugin runtime deps roots from OpenClaw state env", () => { + expect( + collectLegacyPluginRuntimeDepsStateRoots({ + env: { + HOME: "/users/alice", + OPENCLAW_HOME: "/srv/openclaw-home", + OPENCLAW_CONFIG_PATH: "~/profile/openclaw.json", + OPENCLAW_STATE_DIR: "~/state", + STATE_DIRECTORY: "/var/lib/openclaw", + }, + homedir: () => "/users/alice", + }), + ).toEqual([ + "/srv/openclaw-home/.clawdbot/plugin-runtime-deps", + "/srv/openclaw-home/.openclaw/plugin-runtime-deps", + "/srv/openclaw-home/profile/plugin-runtime-deps", + "/srv/openclaw-home/state/plugin-runtime-deps", + "/var/lib/openclaw/plugin-runtime-deps", + ]); + }); + it("keeps imported dist chunks even when inventory is stale", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-import-"); const entryFile = path.join(packageRoot, "dist", "cli", "run-main.js");