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,