fix(plugins): invalidate runtime deps cache on package upgrade

This commit is contained in:
Peter Steinberger
2026-05-01 11:39:37 +01:00
parent f3d5c54884
commit 931e60723d
3 changed files with 215 additions and 1 deletions

View File

@@ -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-<version>-<hash>` 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.

View File

@@ -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<typeof loadOpenClawPlugins>[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();

View File

@@ -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,