fix(plugins): canonicalize packageRoot before hashing runtime-deps stage key (#75048)

Merged via squash.

Prepared head SHA: 324859fc69
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Reviewed-by: @openperf
This commit is contained in:
Chunyue Wang
2026-05-01 09:56:13 +08:00
committed by GitHub
parent bd20f8e07e
commit 4b98f09529
4 changed files with 52 additions and 2 deletions

View File

@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: add an opt-in `agents.defaults.compaction.midTurnPrecheck` mid-turn precheck that detects tool-loop context pressure and triggers compaction before the next tool call instead of waiting for end-of-turn. (#73499) Thanks @marchpure and @haoxingjun.
- Gateway/approvals: let loopback token/password-backed native approval clients resolve exec approvals without attaching stale paired Gateway identities, while remote and unauthenticated approval clients keep normal device identity behavior. (#74472)
- Gateway/config: include rejected validation paths in foreground and service last-known-good recovery logs plus main-agent notices, so unsupported direct edits explain which key caused restore instead of looking like silent reversion. Fixes #75060. Thanks @amknight.
- Plugins/runtime-deps: hash the OS-canonical `packageRoot` via `fs.realpathSync.native` (with `path.resolve` fallback) when computing the bundled runtime-deps stage key, so loader and channel `bundled-root` callers no longer derive divergent stage directories under `~/.openclaw/plugin-runtime-deps/openclaw-<version>-<hash>/` and bundled channels stop failing with `ENOENT` on shared dist chunks under Windows npm symlinks, junctions, or PM2 multi-instance worker layouts. Fixes #74963. (#75048) Thanks @openperf and @vincentkoc.
## 2026.4.29

View File

@@ -53,7 +53,9 @@ function isPackagedBundledPluginRoot(pluginRoot: string): boolean {
}
function createPathHash(value: string): string {
return createHash("sha256").update(path.resolve(value)).digest("hex").slice(0, 12);
// Hash the OS-canonical (realpath) form so symlinked / junctioned
// packageRoots converge on a single staging directory across call sites.
return createHash("sha256").update(realpathOrResolve(value)).digest("hex").slice(0, 12);
}
function sanitizePathSegment(value: string): string {

View File

@@ -2645,6 +2645,41 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(path.basename(resolved).startsWith("openclaw-unknown-")).toBe(false);
});
const itSupportsPackageRootSymlinks = process.platform === "win32" ? it.skip : it;
itSupportsPackageRootSymlinks(
"stages bundled runtime deps to the same root for symlinked packageRoot views (issue #74963)",
() => {
const realParent = makeTempDir();
const stageDir = makeTempDir();
const realPackageRoot = path.join(realParent, "openclaw-real");
fs.mkdirSync(realPackageRoot, { recursive: true });
fs.writeFileSync(
path.join(realPackageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.27" }),
);
const realPluginRoot = path.join(realPackageRoot, "dist", "extensions", "discord");
fs.mkdirSync(realPluginRoot, { recursive: true });
fs.writeFileSync(
path.join(realPluginRoot, "package.json"),
JSON.stringify({ dependencies: {} }),
);
const linkedPackageRoot = path.join(realParent, "openclaw-linked");
fs.symlinkSync(realPackageRoot, linkedPackageRoot, "dir");
const linkedPluginRoot = path.join(linkedPackageRoot, "dist", "extensions", "discord");
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
const installRootViaReal = resolveBundledRuntimeDependencyInstallRoot(realPluginRoot, {
env,
});
const installRootViaLink = resolveBundledRuntimeDependencyInstallRoot(linkedPluginRoot, {
env,
});
expect(installRootViaLink).toBe(installRootViaReal);
expect(path.basename(installRootViaReal)).toMatch(/^openclaw-2026\.4\.27-[0-9a-f]{12}$/);
},
);
it("prunes stale unknown external runtime roots while keeping newest and locked roots", () => {
const stageDir = makeTempDir();
const nowMs = Date.parse("2026-04-29T08:00:00.000Z");

View File

@@ -918,13 +918,25 @@ describe("bundledRuntimeDependencySentinelCandidates", () => {
"playwright-core",
{ HOME: homeRoot } as NodeJS.ProcessEnv,
);
const realRootCandidates = bundledRuntimeDependencySentinelCandidates(
packageRoot,
"browser",
"playwright-core",
{ HOME: homeRoot } as NodeJS.ProcessEnv,
);
const externalCandidates = candidates.filter(
(candidate) =>
candidate.startsWith(join(homeRoot, ".openclaw", "plugin-runtime-deps")) &&
candidate.endsWith(join("node_modules", "playwright-core", "package.json")),
);
const realRootExternalCandidates = realRootCandidates.filter(
(candidate) =>
candidate.startsWith(join(homeRoot, ".openclaw", "plugin-runtime-deps")) &&
candidate.endsWith(join("node_modules", "playwright-core", "package.json")),
);
expect(externalCandidates.length).toBeGreaterThanOrEqual(2);
expect(externalCandidates).toEqual(realRootExternalCandidates);
expect(externalCandidates).toHaveLength(1);
} finally {
rmSync(root, { recursive: true, force: true });
}