diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef1e404d8f..a518d779ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Discord/voice: merge configured media-understanding providers such as Deepgram into partial active provider registries, so follow-up voice turns keep transcribing after another media plugin is already active. Fixes #65687. Thanks @OneMintJulep. - Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao. - Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram. +- Plugins/runtime-deps: include packaged OpenClaw identity in bundled plugin loader cache keys, so same-path package upgrades stop reusing stale versioned runtime-deps mirrors. Fixes #75045. Thanks @sahilsatralkar. - Plugins/runtime-deps: prune inactive same-package versioned runtime-deps roots after bundled dependency repair, so upgrades do not leave old `openclaw--` package caches behind after doctor runs. Thanks @vincentkoc. - Plugins/runtime-deps: prune legacy version-scoped plugin runtime-deps roots during bundled dependency repair and cover the path in Package Acceptance's upgrade-survivor matrix, so upgrades from 2026.4.x no longer leave stale per-plugin runtime trees after doctor runs. Thanks @vincentkoc. - Plugins/runtime-deps: keep Gateway startup plugin imports and runtime plugin fallback loads verify-only after startup/config repair planning, so packaged installs no longer spawn package-manager repair from hot paths after readiness. Refs #75283 and #75069. Thanks @brokemac79 and @xiaohuaxi. diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 796522d5d2f..dc949126235 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -23,7 +23,10 @@ import { } from "../tasks/detached-task-runtime-state.js"; import { withEnv } from "../test-utils/env.js"; import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -import { resolveBundledRuntimeDependencyInstallRootPlan } from "./bundled-runtime-deps-roots.js"; +import { + resolveBundledRuntimeDependencyInstallRootPlan, + resolveBundledRuntimeDependencyPackageInstallRoot, +} from "./bundled-runtime-deps-roots.js"; import { ensureOpenClawPluginSdkAlias } from "./bundled-runtime-root.js"; import { clearPluginCommands } from "./command-registry-state.js"; import { getPluginCommandSpecs } from "./command-specs.js"; @@ -95,6 +98,10 @@ import { ensurePluginRegistryLoaded, } from "./runtime/runtime-registry-loader.js"; import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; +import { + writeGeneratedRuntimeDepsManifest, + writeInstalledRuntimeDepPackage, +} from "./test-helpers/bundled-runtime-deps-fixtures.js"; let cachedBundledTelegramDir = ""; let cachedBundledMemoryDir = ""; @@ -118,6 +125,14 @@ function createDetachedTaskRuntimeStub(id: string): DetachedTaskLifecycleRuntime }; } +function realpathOrResolveForTest(value: string): string { + try { + return fs.realpathSync.native(value); + } catch { + return path.resolve(value); + } +} + const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = { id: "telegram", register(api) { @@ -1592,6 +1607,136 @@ module.exports = { expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); }); + it("does not reuse cached bundled runtime deps after an in-place package version upgrade", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + const markerDir = makeTempDir(); + const markerPath = path.join(markerDir, "browser-runtime-marker.json"); + const bundledDir = path.join(packageRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledDir, "browser"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/browser", + version: "1.0.0", + dependencies: { + "browser-runtime": "1.0.0", + }, + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "browser", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_PLUGIN_STAGE_DIR: stageDir, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + VITEST: "true", + }; + const writePackageVersion = (version: string) => { + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version, type: "module" }, null, 2), + "utf-8", + ); + }; + const writeRuntimeEntry = (marker: string) => { + fs.writeFileSync( + path.join(pluginRoot, "index.cjs"), + ` +const fs = require("node:fs"); +const runtimeDep = require("browser-runtime/package.json"); +fs.writeFileSync( + ${JSON.stringify(markerPath)}, + JSON.stringify({ marker: ${JSON.stringify(marker)}, filename: __filename, runtimeDep: runtimeDep.name }) + "\\n", + "utf-8", +); +module.exports = { id: "browser", register() {} }; +`, + "utf-8", + ); + }; + const installRoots: string[] = []; + const loadOptions = { + env, + onlyPluginIds: ["browser"], + config: { + plugins: { + enabled: true, + }, + }, + bundledRuntimeDepsInstaller: ({ installRoot, installSpecs, missingSpecs }) => { + installRoots.push(installRoot); + writeInstalledRuntimeDepPackage(installRoot, "browser-runtime", "1.0.0"); + writeGeneratedRuntimeDepsManifest(installRoot, installSpecs ?? missingSpecs); + }, + } satisfies Parameters[0]; + + writePackageVersion("2026.4.26"); + writeRuntimeEntry("v26"); + const first = withEnv(env, () => loadOpenClawPlugins(loadOptions)); + const firstInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { + env, + }); + const firstPlugin = first.plugins.find((entry) => entry.id === "browser"); + expect(firstPlugin?.error).toBeUndefined(); + expect(firstPlugin?.status).toBe("loaded"); + const firstMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as { + filename: string; + marker: string; + runtimeDep: string; + }; + + expect(firstMarker.marker).toBe("v26"); + expect(firstMarker.runtimeDep).toBe("browser-runtime"); + expect(realpathOrResolveForTest(firstMarker.filename)).toContain( + realpathOrResolveForTest(path.join(firstInstallRoot, "dist", "extensions")), + ); + expect(installRoots.map((root) => realpathOrResolveForTest(root))).toContain( + realpathOrResolveForTest(firstInstallRoot), + ); + + writePackageVersion("2026.4.27"); + writeRuntimeEntry("v27"); + const secondInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { + env, + }); + const second = withEnv(env, () => loadOpenClawPlugins(loadOptions)); + const secondMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as { + filename: string; + marker: string; + runtimeDep: string; + }; + + expect(second).not.toBe(first); + expect(second.plugins.find((entry) => entry.id === "browser")?.status).toBe("loaded"); + expect(secondMarker.marker).toBe("v27"); + expect(secondMarker.runtimeDep).toBe("browser-runtime"); + expect(realpathOrResolveForTest(secondMarker.filename)).toContain( + realpathOrResolveForTest(path.join(secondInstallRoot, "dist", "extensions")), + ); + expect(secondInstallRoot).not.toBe(firstInstallRoot); + }); + it("loads bundled plugins from symlinked package roots with an external stage dir", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3d3a17e21de..bd482f00db8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -544,6 +544,72 @@ function setCachedPluginRegistry(cacheKey: string, state: CachedPluginState): vo pluginLoaderCacheState.set(cacheKey, state); } +function resolveBundledPackageRootForCache(stockRoot?: string): string | undefined { + if (!stockRoot) { + return undefined; + } + const resolved = path.resolve(stockRoot); + const parent = path.dirname(resolved); + if ( + path.basename(resolved) === "extensions" && + (path.basename(parent) === "dist" || path.basename(parent) === "dist-runtime") + ) { + return path.dirname(parent); + } + const sourcePackageRoot = parent; + if (fs.existsSync(path.join(sourcePackageRoot, "package.json"))) { + return sourcePackageRoot; + } + return undefined; +} + +function readPackageVersionForCache(packageJsonPath: string): string { + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return "unknown"; + } + const version = (parsed as { version?: unknown }).version; + return typeof version === "string" && version.trim() ? version.trim() : "unknown"; + } catch { + return "unknown"; + } +} + +function resolveBundledPackageCacheIdentity(stockRoot?: string): + | { + packageJson: string; + packageRoot: string; + packageVersion: string; + size: number; + mtimeMs: number; + } + | undefined { + const packageRoot = resolveBundledPackageRootForCache(stockRoot); + if (!packageRoot) { + return undefined; + } + const packageJsonPath = path.join(packageRoot, "package.json"); + try { + const stat = fs.statSync(packageJsonPath); + return { + packageJson: safeRealpathOrResolve(packageJsonPath), + packageRoot: safeRealpathOrResolve(packageRoot), + packageVersion: readPackageVersionForCache(packageJsonPath), + size: stat.size, + mtimeMs: stat.mtimeMs, + }; + } catch { + return { + packageJson: path.resolve(packageJsonPath), + packageRoot: safeRealpathOrResolve(packageRoot), + packageVersion: "missing", + size: -1, + mtimeMs: -1, + }; + } +} + function buildCacheKey(params: { workspaceDir?: string; plugins: NormalizedPluginsConfig; @@ -567,6 +633,7 @@ function buildCacheKey(params: { loadPaths: params.plugins.loadPaths, env: params.env, }); + const bundledPackage = resolveBundledPackageCacheIdentity(roots.stock); const installs = Object.fromEntries( Object.entries(params.installs ?? {}).map(([pluginId, install]) => [ pluginId, @@ -600,6 +667,7 @@ function buildCacheKey(params: { const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []); const activationMode = params.activate === false ? "snapshot" : "active"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ + bundledPackage, ...params.plugins, installs, loadPaths,