From 1610b4983fc5d7e7c092efff6616420d1610c96a Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 28 May 2026 17:17:04 -0600 Subject: [PATCH] fix: scope jiti transform cache by OpenClaw install Scope jiti filesystem transform caches for OpenClaw plugin loaders by package version and package.json install metadata so stale transforms cannot survive upgrades or package reinstalls. Covers the central plugin module loader and the plugin SDK root alias CJS loader, while preserving jiti filesystem-cache env opt-outs and the TMPDIR cwd guard. Verification: CI run 26601117143 passed; Real behavior proof run 26601445285 passed; CodeQL selected checks passed in run 26601117126; CodeQL Critical Quality plugin-boundary and plugin-sdk-package-contract passed in run 26601117074; OpenGrep PR diff passed in run 26601117137. Refs: https://github.com/openclaw/openclaw/pull/87745 Thanks @fuller-stack-dev. --- src/plugin-sdk/root-alias.cjs | 74 ++++++++++++++ .../contracts/plugin-sdk-root-alias.test.ts | 42 ++++++++ .../plugin-module-loader-cache.test.ts | 3 + src/plugins/plugin-module-loader-cache.ts | 4 +- src/plugins/sdk-alias.test.ts | 97 +++++++++++++++++++ src/plugins/sdk-alias.ts | 87 ++++++++++++++++- 6 files changed, 305 insertions(+), 2 deletions(-) diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 1973f7a7eac..82db2b21000 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -2,6 +2,7 @@ const path = require("node:path"); const fs = require("node:fs"); +const os = require("node:os"); let monolithicSdk = null; let diagnosticEventsModule = null; @@ -325,6 +326,78 @@ function buildPluginSdkAliasMap(useDist) { return aliasMap; } +function sanitizeJitiCachePathSegment(value) { + const normalized = String(value) + .replace(/[^A-Za-z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized.length > 0 ? normalized : "unknown"; +} + +function resolveJitiFsCacheTmpDir() { + let tmpDir = os.tmpdir(); + if ( + process.env.TMPDIR && + tmpDir === process.cwd() && + !process.env.JITI_RESPECT_TMPDIR_ENV + ) { + const originalTmpDir = process.env.TMPDIR; + delete process.env.TMPDIR; + try { + tmpDir = os.tmpdir(); + } finally { + process.env.TMPDIR = originalTmpDir; + } + } + return tmpDir; +} + +function readJitiBooleanEnv(name, defaultValue) { + if (!(name in process.env)) { + return defaultValue; + } + try { + return Boolean(JSON.parse(process.env[name] ?? "")); + } catch { + return defaultValue; + } +} + +function shouldUseJitiFsCache() { + return readJitiBooleanEnv("JITI_FS_CACHE", readJitiBooleanEnv("JITI_CACHE", true)); +} + +function resolvePluginSdkJitiFsCacheDir() { + const packageRoot = getPackageRoot(); + const packageJsonPath = path.join(packageRoot, "package.json"); + let version = "unknown"; + let installMarker = "no-package-json"; + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + if (typeof parsed.version === "string" && parsed.version.trim().length > 0) { + version = parsed.version; + } + } catch { + // Keep the root alias load path best-effort when package metadata is unavailable. + } + try { + const stat = fs.statSync(packageJsonPath); + installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`; + } catch { + // Package installs should have package.json, but source/test graphs may stub it. + } + return path.join( + resolveJitiFsCacheTmpDir(), + "jiti", + "openclaw", + sanitizeJitiCachePathSegment(version), + sanitizeJitiCachePathSegment(installMarker), + ); +} + +function resolvePluginSdkJitiFsCacheOption() { + return shouldUseJitiFsCache() ? resolvePluginSdkJitiFsCacheDir() : false; +} + function getModuleLoader(tryNative) { if (moduleLoaders.has(tryNative)) { return moduleLoaders.get(tryNative); @@ -334,6 +407,7 @@ function getModuleLoader(tryNative) { const moduleLoader = createJiti(__filename, { alias: buildPluginSdkAliasMap(tryNative), interopDefault: true, + fsCache: resolvePluginSdkJitiFsCacheOption(), // Prefer Node's native sync ESM loader for built dist/plugin-sdk/*.js files // so local plugins do not create a second transpiled OpenClaw core graph. tryNative, diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 9d9c7c9519b..b700dff46c5 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -67,10 +67,13 @@ function loadRootAliasWithStubs(options?: { env?: Record; monolithicExports?: Record; aliasPath?: string; + cwd?: string; + defaultTmpDir?: string; packageExports?: Record; platform?: string; existingPaths?: string[]; privateLocalOnlySubpaths?: unknown; + packageVersion?: string; }) { let createJitiCalls = 0; let jitiLoadCalls = 0; @@ -83,6 +86,7 @@ function loadRootAliasWithStubs(options?: { process: { env: options?.env ?? {}, platform: options?.platform ?? "darwin", + cwd: () => options?.cwd ?? "/workdir", }, }; const wrapper = vm.runInNewContext( @@ -113,12 +117,14 @@ function loadRootAliasWithStubs(options?: { return JSON.stringify(options?.privateLocalOnlySubpaths ?? []); } return JSON.stringify({ + version: options?.packageVersion ?? "0.0.0-test", exports: { "./plugin-sdk/group-access": { default: "./dist/plugin-sdk/group-access.js" }, ...options?.packageExports, }, }); }, + statSync: () => ({ mtimeMs: 12_345, size: 678 }), existsSync: (targetPath: string) => { if (targetPath.endsWith(path.join("dist", "infra", "diagnostic-events.js"))) { return options?.distExists ?? false; @@ -136,6 +142,14 @@ function loadRootAliasWithStubs(options?: { })), }; } + if (id === "node:os") { + return { + tmpdir: () => + context.process.env.TMPDIR ?? + options?.defaultTmpDir ?? + "/tmp/openclaw-root-alias-test", + }; + } if (id === "jiti") { return { createJiti(_filename: string, jitiOptions?: Record) { @@ -336,6 +350,7 @@ describe("plugin-sdk root alias", () => { monolithicExports: { slowHelper: (): string => "loaded", }, + packageVersion: "3.4.5", }); const lazyRootSdk = lazyModule.moduleExports; @@ -344,11 +359,38 @@ describe("plugin-sdk root alias", () => { expect(lazyModule.createJitiCalls).toBe(1); expect(lazyModule.jitiLoadCalls).toBe(1); expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(false); + expect(lazyModule.createJitiOptions.at(-1)?.fsCache).toBe( + path.join("/tmp/openclaw-root-alias-test", "jiti", "openclaw", "3.4.5", "12345-678"), + ); expect((lazyRootSdk.slowHelper as () => string)()).toBe("loaded"); expect(Object.keys(lazyRootSdk)).toContain("slowHelper"); expectEnumerableConfigurableDescriptor(lazyRootSdk, "slowHelper"); }); + it("preserves jiti's tmpdir guard when root-alias TMPDIR resolves to cwd", () => { + const lazyModule = loadRootAliasWithStubs({ + cwd: "/tmp/openclaw-root-alias-cwd", + defaultTmpDir: "/tmp/openclaw-root-alias-fallback", + env: { TMPDIR: "/tmp/openclaw-root-alias-cwd" }, + packageVersion: "3.4.5", + }); + + expect("slowHelper" in lazyModule.moduleExports).toBe(true); + expect(lazyModule.createJitiOptions.at(-1)?.fsCache).toBe( + path.join("/tmp/openclaw-root-alias-fallback", "jiti", "openclaw", "3.4.5", "12345-678"), + ); + }); + + it("preserves jiti's fs cache environment opt-out for root alias", () => { + const lazyModule = loadRootAliasWithStubs({ + env: { JITI_FS_CACHE: "false" }, + packageVersion: "3.4.5", + }); + + expect("slowHelper" in lazyModule.moduleExports).toBe(true); + expect(lazyModule.createJitiOptions.at(-1)?.fsCache).toBe(false); + }); + it.each([ { name: "prefers source loading when the source root alias runs in development", diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index f73911065d6..4e099340983 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginModuleLoaderFactory } from "./plugin-module-loader-cache.js"; @@ -269,6 +270,8 @@ describe("getCachedPluginModuleLoader", () => { const options = expectJitiOptions(createJiti, 0, "file:///repo/src/plugins/loader.ts", { tryNative: false, }); + expect(options.fsCache).toEqual(expect.any(String)); + expect(String(options.fsCache)).toContain(`${path.sep}jiti${path.sep}openclaw${path.sep}`); expect(options.alias).toEqual({ alpha: "/repo/alpha.js", zeta: "/repo/zeta.js", diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index b4e44d669e2..4deb0b01458 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -193,7 +193,9 @@ function createLazySourceTransformLoader(params: { const jitiLoader = (params.createLoader ?? loadCreateJitiLoaderFactory())( params.loaderFilename, { - ...buildPluginLoaderJitiOptions(params.aliasMap), + ...buildPluginLoaderJitiOptions(params.aliasMap, { + modulePath: params.loaderFilename, + }), tryNative: params.sourceTransformTryNative, }, ); diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index 4186f8a9122..1323e8ddc78 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -16,6 +16,8 @@ import { listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, normalizeJitiAliasTargetPath, + resolvePluginLoaderJitiFsCacheDir, + resolvePluginLoaderJitiFsCacheOption, resolvePluginLoaderModuleConfig, resolvePluginLoaderTryNative, resolveExtensionApiAlias, @@ -49,6 +51,27 @@ function makeTempDir() { return dir; } +function createTrustedOpenClawPackageFixture(version: string) { + const root = makeTempDir(); + fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8"); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify( + { + name: "openclaw", + version, + bin: { openclaw: "openclaw.mjs" }, + exports: { "./plugin-sdk": { default: "./dist/plugin-sdk/index.js" } }, + }, + null, + 2, + ), + "utf-8", + ); + mkdirSafeDir(path.join(root, "dist", "plugins")); + return root; +} + function withCwd(cwd: string, run: () => T): T { const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd); try { @@ -2091,6 +2114,80 @@ describe("buildPluginLoaderAliasMap memoization", () => { }); describe("buildPluginLoaderJitiOptions", () => { + it("scopes jiti fs cache by OpenClaw package version and install metadata", () => { + const root = createTrustedOpenClawPackageFixture("1.2.3-beta.4"); + const tmpDir = path.join(root, "tmp"); + + const fsCache = withEnv({ TMPDIR: tmpDir }, () => + resolvePluginLoaderJitiFsCacheDir({ + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }), + ); + + expect(fsCache).toContain(path.join(tmpDir, "jiti", "openclaw", "1.2.3-beta.4") + path.sep); + expect(path.basename(fsCache)).toMatch(/^\d+-\d+$/u); + }); + + it("preserves jiti's tmpdir guard when TMPDIR resolves to cwd", () => { + const root = createTrustedOpenClawPackageFixture("1.2.3-beta.4"); + + const guardedFsCache = withEnv({ TMPDIR: root, JITI_RESPECT_TMPDIR_ENV: undefined }, () => + withCwd(root, () => + resolvePluginLoaderJitiFsCacheDir({ + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }), + ), + ); + const respectedFsCache = withEnv({ TMPDIR: root, JITI_RESPECT_TMPDIR_ENV: "1" }, () => + withCwd(root, () => + resolvePluginLoaderJitiFsCacheDir({ + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }), + ), + ); + + expect(guardedFsCache).toContain( + path.join("jiti", "openclaw", "1.2.3-beta.4") + path.sep, + ); + expect(guardedFsCache.startsWith(path.join(root, "jiti") + path.sep)).toBe(false); + expect(respectedFsCache).toContain( + path.join(root, "jiti", "openclaw", "1.2.3-beta.4") + path.sep, + ); + }); + + it("adds the versioned fs cache directory to plugin loader jiti options", () => { + const root = createTrustedOpenClawPackageFixture("2.0.0"); + const tmpDir = path.join(root, "tmp"); + + const options = withEnv({ TMPDIR: tmpDir }, () => + buildPluginLoaderJitiOptions( + { "openclaw/plugin-sdk": path.join(root, "dist", "plugin-sdk", "root-alias.cjs") }, + { modulePath: path.join(root, "dist", "plugins", "loader.js") }, + ), + ); + + expect(options.fsCache).toContain(path.join(tmpDir, "jiti", "openclaw", "2.0.0")); + }); + + it("preserves jiti's fs cache environment opt-out", () => { + const root = createTrustedOpenClawPackageFixture("2.0.0"); + + const explicitOptOut = withEnv({ JITI_FS_CACHE: "false" }, () => + resolvePluginLoaderJitiFsCacheOption({ + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }), + ); + const legacyOptOut = withEnv({ JITI_CACHE: "false", JITI_FS_CACHE: undefined }, () => + buildPluginLoaderJitiOptions( + { "openclaw/plugin-sdk": path.join(root, "dist", "plugin-sdk", "root-alias.cjs") }, + { modulePath: path.join(root, "dist", "plugins", "loader.js") }, + ), + ); + + expect(explicitOptOut).toBe(false); + expect(legacyOptOut.fsCache).toBe(false); + }); + it("pre-normalizes and marks alias maps for source transforms", () => { const marker = Symbol.for("pathe:normalizedAlias"); const aliasMap = { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 22d848dbdd2..0c9cc2d0383 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { tryReadJsonSync } from "../infra/json-files.js"; @@ -28,11 +29,50 @@ export type PluginRuntimeModuleResolution = { type PluginSdkPackageJson = { exports?: Record; bin?: string | Record; + version?: string; }; const STARTUP_ARGV1 = process.argv[1]; const pluginSdkPackageJsonByRoot = new Map(); +function sanitizeJitiCachePathSegment(value: string): string { + const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); + return normalized.length > 0 ? normalized : "unknown"; +} + +function resolveJitiFsCacheTmpDir(): string { + let tmpDir = os.tmpdir(); + if ( + process.env.TMPDIR && + tmpDir === process.cwd() && + !process.env.JITI_RESPECT_TMPDIR_ENV + ) { + const originalTmpDir = process.env.TMPDIR; + delete process.env.TMPDIR; + try { + tmpDir = os.tmpdir(); + } finally { + process.env.TMPDIR = originalTmpDir; + } + } + return tmpDir; +} + +function readJitiBooleanEnv(name: string, defaultValue: boolean): boolean { + if (!(name in process.env)) { + return defaultValue; + } + try { + return Boolean(JSON.parse(process.env[name] ?? "")); + } catch { + return defaultValue; + } +} + +function shouldUseJitiFsCache(): boolean { + return readJitiBooleanEnv("JITI_FS_CACHE", readJitiBooleanEnv("JITI_CACHE", true)); +} + export function normalizeJitiAliasTargetPath(targetPath: string): string { return process.platform === "win32" ? targetPath.replace(/\\/g, "/") : targetPath; } @@ -55,6 +95,47 @@ function readPluginSdkPackageJson(packageRoot: string): PluginSdkPackageJson | n return parsed; } +function resolveJitiCacheModulePath(params: LoaderModuleResolveParams = {}): string { + if (params.modulePath?.startsWith("file://")) { + try { + return fileURLToPath(params.modulePath); + } catch { + // Fall through to the shared module resolver for malformed test inputs. + } + } + return resolveLoaderModulePath(params); +} + +export function resolvePluginLoaderJitiFsCacheDir(params: LoaderModuleResolveParams = {}): string { + const modulePath = resolveJitiCacheModulePath(params); + const packageRoot = + resolveLoaderPackageRoot({ ...params, modulePath }) ?? path.dirname(modulePath); + const packageJsonPath = path.join(packageRoot, "package.json"); + const version = sanitizeJitiCachePathSegment( + readPluginSdkPackageJson(packageRoot)?.version ?? "unknown", + ); + let installMarker = "no-package-json"; + try { + const stat = fs.statSync(packageJsonPath); + installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`; + } catch { + // Package installs should have package.json; keep cache setup best-effort. + } + return path.join( + resolveJitiFsCacheTmpDir(), + "jiti", + "openclaw", + version, + sanitizeJitiCachePathSegment(installMarker), + ); +} + +export function resolvePluginLoaderJitiFsCacheOption( + params: LoaderModuleResolveParams = {}, +): false | string { + return shouldUseJitiFsCache() ? resolvePluginLoaderJitiFsCacheDir(params) : false; +} + function isSafePluginSdkSubpathSegment(subpath: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath); } @@ -1163,11 +1244,15 @@ export function resolvePluginRuntimeModulePathWithDiagnostics( }; } -export function buildPluginLoaderJitiOptions(aliasMap: Record) { +export function buildPluginLoaderJitiOptions( + aliasMap: Record, + params: LoaderModuleResolveParams = {}, +) { const hasAliases = Object.keys(aliasMap).length > 0; const jitiAliasMap = hasAliases ? normalizePluginLoaderAliasMapForJiti(aliasMap) : aliasMap; return { interopDefault: true, + fsCache: resolvePluginLoaderJitiFsCacheOption(params), // Prefer Node's native sync ESM loader for built dist/*.js modules so // bundled plugins and plugin-sdk subpaths stay on the canonical module graph. tryNative: true,