Skip to content

Commit 56a7889

Browse files
fix(plugins): invalidate runtime deps cache on package upgrade
1 parent 29d3b65 commit 56a7889

2 files changed

Lines changed: 197 additions & 0 deletions

File tree

src/plugins/loader.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ import {
9797
ensurePluginRegistryLoaded,
9898
} from "./runtime/runtime-registry-loader.js";
9999
import type { PluginSdkResolutionPreference } from "./sdk-alias.js";
100+
import {
101+
writeGeneratedRuntimeDepsManifest,
102+
writeInstalledRuntimeDepPackage,
103+
} from "./test-helpers/bundled-runtime-deps-fixtures.js";
100104
let cachedBundledTelegramDir = "";
101105
let cachedBundledMemoryDir = "";
102106

@@ -1594,6 +1598,131 @@ module.exports = {
15941598
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
15951599
});
15961600

1601+
it("does not reuse cached bundled runtime deps after an in-place package version upgrade", () => {
1602+
const packageRoot = makeTempDir();
1603+
const stageDir = makeTempDir();
1604+
const markerDir = makeTempDir();
1605+
const markerPath = path.join(markerDir, "browser-runtime-marker.json");
1606+
const bundledDir = path.join(packageRoot, "dist", "extensions");
1607+
const pluginRoot = path.join(bundledDir, "browser");
1608+
fs.mkdirSync(pluginRoot, { recursive: true });
1609+
fs.writeFileSync(
1610+
path.join(pluginRoot, "package.json"),
1611+
JSON.stringify(
1612+
{
1613+
name: "@openclaw/browser",
1614+
version: "1.0.0",
1615+
dependencies: {
1616+
"browser-runtime": "1.0.0",
1617+
},
1618+
openclaw: { extensions: ["./index.cjs"] },
1619+
},
1620+
null,
1621+
2,
1622+
),
1623+
"utf-8",
1624+
);
1625+
fs.writeFileSync(
1626+
path.join(pluginRoot, "openclaw.plugin.json"),
1627+
JSON.stringify(
1628+
{
1629+
id: "browser",
1630+
enabledByDefault: true,
1631+
configSchema: EMPTY_PLUGIN_SCHEMA,
1632+
},
1633+
null,
1634+
2,
1635+
),
1636+
"utf-8",
1637+
);
1638+
1639+
const env = {
1640+
...process.env,
1641+
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
1642+
OPENCLAW_PLUGIN_STAGE_DIR: stageDir,
1643+
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
1644+
VITEST: "true",
1645+
};
1646+
const writePackageVersion = (version: string) => {
1647+
fs.writeFileSync(
1648+
path.join(packageRoot, "package.json"),
1649+
JSON.stringify({ name: "openclaw", version, type: "module" }, null, 2),
1650+
"utf-8",
1651+
);
1652+
};
1653+
const writeRuntimeEntry = (marker: string) => {
1654+
fs.writeFileSync(
1655+
path.join(pluginRoot, "index.cjs"),
1656+
`
1657+
const fs = require("node:fs");
1658+
const runtimeDep = require("browser-runtime/package.json");
1659+
fs.writeFileSync(
1660+
${JSON.stringify(markerPath)},
1661+
JSON.stringify({ marker: ${JSON.stringify(marker)}, filename: __filename, runtimeDep: runtimeDep.name }) + "\\n",
1662+
"utf-8",
1663+
);
1664+
module.exports = { id: "browser", register() {} };
1665+
`,
1666+
"utf-8",
1667+
);
1668+
};
1669+
const installRoots: string[] = [];
1670+
const loadOptions = {
1671+
env,
1672+
onlyPluginIds: ["browser"],
1673+
config: {
1674+
plugins: {
1675+
enabled: true,
1676+
},
1677+
},
1678+
bundledRuntimeDepsInstaller: ({ installRoot, installSpecs, missingSpecs }) => {
1679+
installRoots.push(installRoot);
1680+
writeInstalledRuntimeDepPackage(installRoot, "browser-runtime", "1.0.0");
1681+
writeGeneratedRuntimeDepsManifest(installRoot, installSpecs ?? missingSpecs);
1682+
},
1683+
} satisfies Parameters<typeof loadOpenClawPlugins>[0];
1684+
1685+
writePackageVersion("2026.4.26");
1686+
writeRuntimeEntry("v26");
1687+
const first = withEnv(env, () => loadOpenClawPlugins(loadOptions));
1688+
const firstInstallRoot = installRoots.at(-1);
1689+
if (!firstInstallRoot) {
1690+
throw new Error("expected first runtime-deps install root");
1691+
}
1692+
const firstPlugin = first.plugins.find((entry) => entry.id === "browser");
1693+
expect(firstPlugin?.error).toBeUndefined();
1694+
expect(firstPlugin?.status).toBe("loaded");
1695+
const firstMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as {
1696+
filename: string;
1697+
marker: string;
1698+
runtimeDep: string;
1699+
};
1700+
1701+
expect(firstMarker.marker).toBe("v26");
1702+
expect(firstMarker.runtimeDep).toBe("browser-runtime");
1703+
expect(firstMarker.filename).toContain(path.join(firstInstallRoot, "dist", "extensions"));
1704+
1705+
writePackageVersion("2026.4.27");
1706+
writeRuntimeEntry("v27");
1707+
const second = withEnv(env, () => loadOpenClawPlugins(loadOptions));
1708+
const secondInstallRoot = installRoots.at(-1);
1709+
if (!secondInstallRoot) {
1710+
throw new Error("expected second runtime-deps install root");
1711+
}
1712+
const secondMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as {
1713+
filename: string;
1714+
marker: string;
1715+
runtimeDep: string;
1716+
};
1717+
1718+
expect(second).not.toBe(first);
1719+
expect(second.plugins.find((entry) => entry.id === "browser")?.status).toBe("loaded");
1720+
expect(secondMarker.marker).toBe("v27");
1721+
expect(secondMarker.runtimeDep).toBe("browser-runtime");
1722+
expect(secondMarker.filename).toContain(path.join(secondInstallRoot, "dist", "extensions"));
1723+
expect(secondInstallRoot).not.toBe(firstInstallRoot);
1724+
});
1725+
15971726
it("loads bundled plugins from symlinked package roots with an external stage dir", () => {
15981727
const packageRoot = makeTempDir();
15991728
const stageDir = makeTempDir();

src/plugins/loader.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,72 @@ function setCachedPluginRegistry(cacheKey: string, state: CachedPluginState): vo
546546
pluginLoaderCacheState.set(cacheKey, state);
547547
}
548548

549+
function resolveBundledPackageRootForCache(stockRoot?: string): string | undefined {
550+
if (!stockRoot) {
551+
return undefined;
552+
}
553+
const resolved = path.resolve(stockRoot);
554+
const parent = path.dirname(resolved);
555+
if (
556+
path.basename(resolved) === "extensions" &&
557+
(path.basename(parent) === "dist" || path.basename(parent) === "dist-runtime")
558+
) {
559+
return path.dirname(parent);
560+
}
561+
const sourcePackageRoot = parent;
562+
if (fs.existsSync(path.join(sourcePackageRoot, "package.json"))) {
563+
return sourcePackageRoot;
564+
}
565+
return undefined;
566+
}
567+
568+
function readPackageVersionForCache(packageJsonPath: string): string {
569+
try {
570+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as unknown;
571+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
572+
return "unknown";
573+
}
574+
const version = (parsed as { version?: unknown }).version;
575+
return typeof version === "string" && version.trim() ? version.trim() : "unknown";
576+
} catch {
577+
return "unknown";
578+
}
579+
}
580+
581+
function resolveBundledPackageCacheIdentity(stockRoot?: string):
582+
| {
583+
packageJson: string;
584+
packageRoot: string;
585+
packageVersion: string;
586+
size: number;
587+
mtimeMs: number;
588+
}
589+
| undefined {
590+
const packageRoot = resolveBundledPackageRootForCache(stockRoot);
591+
if (!packageRoot) {
592+
return undefined;
593+
}
594+
const packageJsonPath = path.join(packageRoot, "package.json");
595+
try {
596+
const stat = fs.statSync(packageJsonPath);
597+
return {
598+
packageJson: safeRealpathOrResolve(packageJsonPath),
599+
packageRoot: safeRealpathOrResolve(packageRoot),
600+
packageVersion: readPackageVersionForCache(packageJsonPath),
601+
size: stat.size,
602+
mtimeMs: stat.mtimeMs,
603+
};
604+
} catch {
605+
return {
606+
packageJson: path.resolve(packageJsonPath),
607+
packageRoot: safeRealpathOrResolve(packageRoot),
608+
packageVersion: "missing",
609+
size: -1,
610+
mtimeMs: -1,
611+
};
612+
}
613+
}
614+
549615
function buildCacheKey(params: {
550616
workspaceDir?: string;
551617
plugins: NormalizedPluginsConfig;
@@ -569,6 +635,7 @@ function buildCacheKey(params: {
569635
loadPaths: params.plugins.loadPaths,
570636
env: params.env,
571637
});
638+
const bundledPackage = resolveBundledPackageCacheIdentity(roots.stock);
572639
const installs = Object.fromEntries(
573640
Object.entries(params.installs ?? {}).map(([pluginId, install]) => [
574641
pluginId,
@@ -602,6 +669,7 @@ function buildCacheKey(params: {
602669
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
603670
const activationMode = params.activate === false ? "snapshot" : "active";
604671
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
672+
bundledPackage,
605673
...params.plugins,
606674
installs,
607675
loadPaths,

0 commit comments

Comments
 (0)