From 4b98f0952934c26a24a25fb5466920e6542ea61f Mon Sep 17 00:00:00 2001 From: Chunyue Wang <80630709+openperf@users.noreply.github.com> Date: Fri, 1 May 2026 09:56:13 +0800 Subject: [PATCH] fix(plugins): canonicalize packageRoot before hashing runtime-deps stage key (#75048) Merged via squash. Prepared head SHA: 324859fc69c25ac66f51b93edc75e5ca37ca2d6b Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Reviewed-by: @openperf --- CHANGELOG.md | 1 + src/plugins/bundled-runtime-deps-roots.ts | 4 ++- src/plugins/bundled-runtime-deps.test.ts | 35 +++++++++++++++++++++++ test/release-check.test.ts | 14 ++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a869a10ef3..e3cefaf70fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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--/` 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 diff --git a/src/plugins/bundled-runtime-deps-roots.ts b/src/plugins/bundled-runtime-deps-roots.ts index cdd0c8264d8..f21dd222ccb 100644 --- a/src/plugins/bundled-runtime-deps-roots.ts +++ b/src/plugins/bundled-runtime-deps-roots.ts @@ -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 { diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 016d42ea3c2..a06fc88a2ba 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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"); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 0fe41c65e2f..e817fd40bef 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -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 }); }