mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(plugins): invalidate runtime deps cache on package upgrade
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user