diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 99319052aa1..f963f266504 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -8,6 +8,7 @@ import { } from "./bundled-compat.js"; import { resolveBundledPluginRepoEntryPath } from "./bundled-plugin-metadata.js"; import { createCapturedPluginRegistration } from "./captured-registration.js"; +import { resolveOpenClawDevSourceRoot } from "./dev-source-root.js"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -201,6 +202,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: { discovery?: PluginDiscoveryResult; }) { const env = params.env ?? process.env; + const devSourceRoot = resolveOpenClawDevSourceRoot(env); const pluginIds = new Set(params.pluginIds); const registry = createEmptyPluginRegistry(); const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache(); @@ -219,6 +221,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: { process.argv[1], import.meta.url, params.pluginSdkResolution, + devSourceRoot, ), pluginSdkResolution: params.pluginSdkResolution, env, @@ -228,6 +231,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: { cache: moduleLoaders, modulePath, importerUrl: import.meta.url, + devSourceRoot, loaderFilename: import.meta.url, ...(aliasMap ? { aliasMap } : {}), pluginSdkResolution: params.pluginSdkResolution, diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 2e6d8554a2a..441bd11b0f8 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { getCompactionProvider, registerCompactionProvider } from "./compaction-provider.js"; import { getEmbeddingProvider, registerEmbeddingProvider } from "./embedding-providers.js"; @@ -84,6 +87,14 @@ function requireMemoryEmbeddingProvider(providerId: string) { return provider; } +function makeOpenClawDevSourceRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-dev-source-")); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }), "utf-8"); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.mkdirSync(path.join(root, "extensions"), { recursive: true }); + return root; +} + describe("getCompatibleActivePluginRegistry", () => { it("reuses the active registry only when the load context cache key matches", () => { const registry = createEmptyPluginRegistry(); @@ -309,6 +320,31 @@ describe("getCompatibleActivePluginRegistry", () => { expect(cacheKey).not.toContain("telegram configured"); }); + it("separates dev source root precedence in the loader cache key", () => { + const devSourceRoot = makeOpenClawDevSourceRoot(); + try { + const baseOptions = { + config: { + plugins: { + allow: ["demo"], + load: { paths: ["/tmp/demo.js"] }, + }, + }, + env: { ...process.env, OPENCLAW_DEV_SOURCE_ROOT: undefined }, + }; + + const base = testing.resolvePluginLoadCacheContext(baseOptions).cacheKey; + const dev = testing.resolvePluginLoadCacheContext({ + ...baseOptions, + env: { ...process.env, OPENCLAW_DEV_SOURCE_ROOT: devSourceRoot }, + }).cacheKey; + + expect(dev).not.toBe(base); + } finally { + fs.rmSync(devSourceRoot, { recursive: true, force: true }); + } + }); + it("separates raw env substitution mode in the loader cache key", () => { const baseOptions = { config: { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 879f99897d3..fd746e028c2 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -52,6 +52,7 @@ import { type NormalizedPluginsConfig, } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; +import { resolveOpenClawDevSourceRoot } from "./dev-source-root.js"; import { discoverOpenClawPlugins, type PluginCandidate, @@ -545,13 +546,17 @@ function runPluginRegisterSync( } } -function createPluginModuleLoader(options: Pick) { +function createPluginModuleLoader(options: { + devSourceRoot?: string | null; + pluginSdkResolution?: PluginSdkResolutionPreference; +}) { const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache(); const createLoaderForModule = (modulePath: string) => { installOpenClawPluginSdkNativeResolver({ argv1: process.argv[1], moduleUrl: import.meta.url, pluginModulePath: modulePath, + devSourceRoot: options.devSourceRoot, pluginSdkResolution: options.pluginSdkResolution, }); return getCachedPluginModuleLoader({ @@ -559,11 +564,13 @@ function createPluginModuleLoader(options: Pick; env: NodeJS.ProcessEnv; + devSourceRoot?: string | null; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; forceSetupOnlyChannelPlugins?: boolean; @@ -927,6 +935,7 @@ function buildCacheKey(params: { }); const { roots, loadPaths } = discoveryContext; const bundledPackage = resolveBundledPackageCacheIdentity(roots.stock); + const devSourceRoot = params.devSourceRoot ?? ""; const installs = Object.fromEntries( Object.entries(params.installs ?? {}).map(([pluginId, install]) => [ pluginId, @@ -964,6 +973,7 @@ function buildCacheKey(params: { const activationMode = params.activate === false ? "snapshot" : "active"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ bundledPackage, + devSourceRoot, discoveryFingerprint: fingerprintPluginDiscoveryContext(discoveryContext), ...params.plugins, installs, @@ -1280,6 +1290,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { ...(options.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env })), ...cfg.plugins?.installs, }; + const devSourceRoot = resolveOpenClawDevSourceRoot(env); const cacheKey = buildCacheKey({ workspaceDir: options.workspaceDir, plugins: shouldResolveRawConfigEnvVars @@ -1291,6 +1302,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { }), installs: installRecords, env, + devSourceRoot, onlyPluginIds, includeSetupOnlyChannelPlugins, forceSetupOnlyChannelPlugins, @@ -1322,6 +1334,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { shouldLoadModules: options.loadModules !== false, runtimeSubagentMode, installRecords, + devSourceRoot, cacheKey, }; } @@ -1717,6 +1730,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi cacheKey, runtimeSubagentMode, installRecords, + devSourceRoot, } = resolvePluginLoadCacheContext(options); const logger = options.logger ?? defaultLogger(); const validateOnly = options.mode === "validate"; @@ -1765,7 +1779,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Lazy: avoid creating module loaders when all plugins are disabled (common in unit tests). - const loadPluginModule = createPluginModuleLoader(options); + const loadPluginModule = createPluginModuleLoader({ + devSourceRoot, + pluginSdkResolution: options.pluginSdkResolution, + }); let createPluginRuntimeFactory: | ((options?: CreatePluginRuntimeOptions) => PluginRuntime) @@ -1777,6 +1794,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return createPluginRuntimeFactory; } const runtimeModuleResolution = resolvePluginRuntimeModulePathWithDiagnostics({ + devSourceRoot, pluginSdkResolution: options.pluginSdkResolution, }); const runtimeModulePath = runtimeModuleResolution.resolvedPath; @@ -2772,13 +2790,17 @@ export async function loadOpenClawPluginCliRegistry( onlyPluginIds, cacheKey, installRecords, + devSourceRoot, } = resolvePluginLoadCacheContext({ ...options, activate: false, }); const logger = options.logger ?? defaultLogger(); const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds); - const loadPluginModule = createPluginModuleLoader(options); + const loadPluginModule = createPluginModuleLoader({ + devSourceRoot, + pluginSdkResolution: options.pluginSdkResolution, + }); const { registry, registerCli } = createPluginRegistry({ logger, runtime: {} as PluginRuntime, diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index 89383b6bccf..a63c2291fac 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -27,6 +27,7 @@ export type ResolvePluginModuleLoaderCacheEntryParams = { loaderFilename?: string; aliasMap?: Record; tryNative?: boolean; + devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; cacheScopeKey?: string; sharedCacheScopeKey?: string; @@ -134,6 +135,7 @@ function resolveDefaultPluginModuleLoaderConfig( modulePath: params.modulePath, argv1: params.argvEntry ?? process.argv[1], moduleUrl: params.importerUrl, + devSourceRoot: params.devSourceRoot, ...(params.preferBuiltDist ? { preferBuiltDist: true } : {}), ...(params.pluginSdkResolution ? { pluginSdkResolution: params.pluginSdkResolution } : {}), }); diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index 42cff2872dc..34111c53d79 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -79,6 +79,18 @@ function writeNormalizationCoreSource(root: string): string { return sourcePath; } +function addFakePluginSdkDistExport(root: string, subpath: string): string { + const packageJsonPath = path.join(root, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + exports: Record; + }; + const distPath = path.join(root, "dist", "plugin-sdk", `${subpath}.js`); + packageJson.exports[`./plugin-sdk/${subpath}`] = `./dist/plugin-sdk/${subpath}.js`; + writeJsonFile(packageJsonPath, packageJson); + fs.writeFileSync(distPath, `export const ${subpath.replaceAll("-", "_")} = true;\n`, "utf8"); + return distPath; +} + describe("installOpenClawPluginSdkNativeResolver", () => { it("resolves installed plugin SDK imports to the dev source root", () => { const stableRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-stable-")); @@ -111,6 +123,85 @@ describe("installOpenClawPluginSdkNativeResolver", () => { } }); + it("resolves installed plugin SDK imports to an explicit dev source root", () => { + const stableRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-stable-")); + const devRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-dev-source-")); + const { loaderModulePath } = writeFakeOpenClawPackage(stableRoot); + writeFakeOpenClawPackage(devRoot); + fs.mkdirSync(path.join(devRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(devRoot, "extensions"), { recursive: true }); + const externalPluginEntry = writeExternalPluginEntry(path.join(stableRoot, "external-plugin")); + + const installedAliases = installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: externalPluginEntry, + devSourceRoot: devRoot, + }); + + expect(installedAliases).toContain("openclaw/plugin-sdk/agent-runtime"); + const requireFromPlugin = createRequire(externalPluginEntry); + expect(fs.realpathSync(requireFromPlugin.resolve("openclaw/plugin-sdk/agent-runtime"))).toBe( + fs.realpathSync(path.join(devRoot, "dist", "plugin-sdk", "agent-runtime.js")), + ); + }); + + it("updates native SDK aliases when the same plugin parent switches dev source roots", () => { + const stableRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-stable-")); + const devRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-dev-source-")); + const { loaderModulePath } = writeFakeOpenClawPackage(stableRoot); + writeFakeOpenClawPackage(devRoot); + fs.mkdirSync(path.join(devRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(devRoot, "extensions"), { recursive: true }); + const externalPluginEntry = writeExternalPluginEntry(path.join(stableRoot, "external-plugin")); + const requireFromPlugin = createRequire(externalPluginEntry); + + installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: externalPluginEntry, + }); + expect(fs.realpathSync(requireFromPlugin.resolve("openclaw/plugin-sdk/agent-runtime"))).toBe( + fs.realpathSync(path.join(stableRoot, "dist", "plugin-sdk", "agent-runtime.js")), + ); + + installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: externalPluginEntry, + devSourceRoot: devRoot, + }); + + expect(fs.realpathSync(requireFromPlugin.resolve("openclaw/plugin-sdk/agent-runtime"))).toBe( + fs.realpathSync(path.join(devRoot, "dist", "plugin-sdk", "agent-runtime.js")), + ); + }); + + it("removes stale native SDK aliases when a later dev root omits a subpath", () => { + const stableRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-stable-")); + const devRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-dev-source-")); + const { loaderModulePath } = writeFakeOpenClawPackage(stableRoot); + writeFakeOpenClawPackage(devRoot); + const stableExtraPath = addFakePluginSdkDistExport(stableRoot, "stable-extra"); + fs.mkdirSync(path.join(devRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(devRoot, "extensions"), { recursive: true }); + const externalPluginEntry = writeExternalPluginEntry(path.join(stableRoot, "external-plugin")); + const requireFromPlugin = createRequire(externalPluginEntry); + + installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: externalPluginEntry, + }); + expect(fs.realpathSync(requireFromPlugin.resolve("openclaw/plugin-sdk/stable-extra"))).toBe( + fs.realpathSync(stableExtraPath), + ); + + installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: externalPluginEntry, + devSourceRoot: devRoot, + }); + + expect(() => requireFromPlugin.resolve("openclaw/plugin-sdk/stable-extra")).toThrow(); + }); + it("keeps native aliases on JS dist artifacts when source files exist", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-source-resolver-")); const { loaderModulePath } = writeFakeOpenClawPackage(root); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 199d7a93164..bf2a1701a89 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -38,6 +38,7 @@ export type InstallOpenClawPluginSdkNativeResolverOptions = { allowedParentRoots?: readonly string[]; argv1?: string; moduleUrl?: string; + devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; }; @@ -220,6 +221,7 @@ function listPluginSdkNativeAliases( // Native require hooks must point at JavaScript artifacts, even when the // plugin loader itself is configured to prefer source imports. "dist", + options.devSourceRoot, ), ) .filter(([specifier]) => isPluginSdkAliasSpecifier(specifier)) @@ -302,9 +304,9 @@ function registerNativeAlias(params: { }): void { const entries = pluginSdkNativeAliases.get(params.request) ?? []; for (const parentRoot of params.parentRoots) { - if ( - entries.some((entry) => entry.parentRoot === parentRoot && entry.target === params.target) - ) { + const existingIndex = entries.findIndex((entry) => entry.parentRoot === parentRoot); + if (existingIndex !== -1) { + entries[existingIndex] = { parentRoot, target: params.target }; continue; } entries.push({ parentRoot, target: params.target }); @@ -314,10 +316,26 @@ function registerNativeAlias(params: { } } +function clearNativeAliasesForParentRoots(parentRoots: readonly string[]): void { + if (parentRoots.length === 0) { + return; + } + const parentRootSet = new Set(parentRoots); + for (const [request, entries] of pluginSdkNativeAliases) { + const nextEntries = entries.filter((entry) => !parentRootSet.has(entry.parentRoot)); + if (nextEntries.length === 0) { + pluginSdkNativeAliases.delete(request); + } else { + pluginSdkNativeAliases.set(request, nextEntries); + } + } +} + export function installOpenClawPluginSdkNativeResolver( options: InstallOpenClawPluginSdkNativeResolverOptions = {}, ): string[] { const parentRoots = resolveAllowedParentRoots(options); + clearNativeAliasesForParentRoots(parentRoots); for (const [specifier, target] of listPluginSdkNativeAliases(options)) { registerNativeAlias({ request: specifier, target, parentRoots }); } diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index 06bac5d312c..ab651893d41 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -25,6 +25,7 @@ import { resolvePluginRuntimeModulePathWithDiagnostics, resolvePluginSdkAliasFile, shouldPreferNativeModuleLoad, + type PluginSdkResolutionPreference, } from "./sdk-alias.js"; import { cleanupTrackedTempDirs, @@ -350,12 +351,16 @@ function resolvePluginSdkAlias(params: { function resolvePluginRuntimeModule(params: { modulePath: string; argv1?: string; + devSourceRoot?: string | null; env?: NodeJS.ProcessEnv; + pluginSdkResolution?: PluginSdkResolutionPreference; }) { const run = () => resolvePluginRuntimeModulePath({ modulePath: params.modulePath, argv1: params.argv1, + devSourceRoot: params.devSourceRoot, + pluginSdkResolution: params.pluginSdkResolution, }); return params.env ? withEnv(params.env, run) : run(); } @@ -615,6 +620,25 @@ describe("plugin sdk alias helpers", () => { }); }); + it("resolves extension-api aliases from an explicit dev source root", () => { + const stableFixture = createExtensionApiAliasFixture({ + distBody: "export const stableExtensionApi = true;\n", + }); + const devFixture = createExtensionApiAliasFixture({ + distBody: "export const devExtensionApi = true;\n", + }); + mkdirSafeDir(path.join(devFixture.root, "extensions")); + const entry = path.join(stableFixture.root, "dist", "plugins", "loader.js"); + mkdirSafeDir(path.dirname(entry)); + fs.writeFileSync(entry, "export {};\n", "utf-8"); + + const aliases = buildPluginLoaderAliasMap(entry, undefined, undefined, "dist", devFixture.root); + + expect(fs.realpathSync(aliases["openclaw/extension-api"] ?? "")).toBe( + fs.realpathSync(devFixture.distFile), + ); + }); + it.each([ { name: "prefers dist candidates first for production src runtime", @@ -1023,10 +1047,44 @@ describe("plugin sdk alias helpers", () => { fixture.root, bundledPluginFile("codex", "src/index.ts"), ); + const distCodexEntry = writePluginEntry( + fixture.root, + path.join("dist", "extensions", "codex", "index.js"), + ); const sourceOtherPluginEntry = writePluginEntry( fixture.root, bundledPluginFile("demo", "src/index.ts"), ); + const devFixture = createPluginSdkAliasFixture({ + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + }, + }); + const devRootAlias = path.join(devFixture.root, "dist", "plugin-sdk", "root-alias.cjs"); + const devCodexMcpProjectionPath = path.join( + devFixture.root, + "dist", + "plugin-sdk", + "codex-mcp-projection.js", + ); + const devCodexNativeTaskRuntimePath = path.join( + devFixture.root, + "dist", + "plugin-sdk", + "codex-native-task-runtime.js", + ); + mkdirSafeDir(path.join(devFixture.root, "extensions")); + fs.writeFileSync(devRootAlias, "module.exports = {};\n", "utf-8"); + fs.writeFileSync( + devCodexMcpProjectionPath, + "export const devCodexMcpProjection = true;\n", + "utf-8", + ); + fs.writeFileSync( + devCodexNativeTaskRuntimePath, + "export const devCodexNativeTaskRuntime = true;\n", + "utf-8", + ); const { packageRoot: installedCodexRoot, pluginEntry: installedCodexEntry } = writeInstalledPluginEntry({ installRoot: path.join(makeTempDir(), ".openclaw", "npm"), @@ -1055,6 +1113,17 @@ describe("plugin sdk alias helpers", () => { { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, () => buildPluginLoaderAliasMap(sourceOtherPluginEntry), ); + const devRootAliases = withEnv( + { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, + () => + buildPluginLoaderAliasMap( + distCodexEntry, + path.join(fixture.root, "openclaw.mjs"), + undefined, + "dist", + devFixture.root, + ), + ); const installedAliases = withCwd(installedCodexRoot, () => withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, () => buildPluginLoaderAliasMap( @@ -1101,6 +1170,15 @@ describe("plugin sdk alias helpers", () => { expect( fs.realpathSync(installedAliases["openclaw/plugin-sdk/codex-native-task-runtime"] ?? ""), ).toBe(fs.realpathSync(distCodexNativeTaskRuntimePath)); + expect(fs.realpathSync(devRootAliases["openclaw/plugin-sdk"] ?? "")).toBe( + fs.realpathSync(devRootAlias), + ); + expect(fs.realpathSync(devRootAliases["openclaw/plugin-sdk/codex-mcp-projection"] ?? "")).toBe( + fs.realpathSync(devCodexMcpProjectionPath), + ); + expect( + fs.realpathSync(devRootAliases["openclaw/plugin-sdk/codex-native-task-runtime"] ?? ""), + ).toBe(fs.realpathSync(devCodexNativeTaskRuntimePath)); expect(aliases["openclaw/plugin-sdk/qa-runtime"]).toBeUndefined(); expect(otherAliases["openclaw/plugin-sdk/codex-mcp-projection"]).toBeUndefined(); expect(otherAliases["openclaw/plugin-sdk/codex-native-task-runtime"]).toBeUndefined(); @@ -2066,6 +2144,27 @@ export const syntheticRuntimeMarker = { expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); + it("resolves plugin runtime modules from an explicit dev source root", () => { + const stableFixture = createPluginRuntimeAliasFixture({ + distBody: "export const stableRuntime = true;\n", + }); + const devFixture = createPluginRuntimeAliasFixture({ + distBody: "export const devRuntime = true;\n", + }); + mkdirSafeDir(path.join(devFixture.root, "extensions")); + const entry = path.join(stableFixture.root, "dist", "plugins", "loader.js"); + mkdirSafeDir(path.dirname(entry)); + fs.writeFileSync(entry, "export {};\n", "utf-8"); + + const resolved = resolvePluginRuntimeModule({ + modulePath: entry, + pluginSdkResolution: "dist", + devSourceRoot: devFixture.root, + }); + + expect(fs.realpathSync(resolved ?? "")).toBe(fs.realpathSync(devFixture.distFile)); + }); + it("falls back to ancestor runtime candidates when package-root markers are unavailable", () => { const root = makeTempDir(); const distFile = path.join(root, "dist", "plugins", "runtime", "index.js"); @@ -2279,6 +2378,37 @@ describe("buildPluginLoaderAliasMap memoization", () => { expect(a).not.toBe(b); }); + it("returns different references when an explicit dev source root differs", () => { + const stableFixture = createPluginSdkAliasFixture(); + const devFixture = createPluginSdkAliasFixture(); + const stableRootAlias = path.join(stableFixture.root, "dist", "plugin-sdk", "root-alias.cjs"); + const devRootAlias = path.join(devFixture.root, "dist", "plugin-sdk", "root-alias.cjs"); + mkdirSafeDir(path.join(devFixture.root, "extensions")); + fs.writeFileSync(stableRootAlias, "module.exports = { stable: true };\n", "utf-8"); + fs.writeFileSync(devRootAlias, "module.exports = { dev: true };\n", "utf-8"); + const entry = writePluginEntry( + stableFixture.root, + bundledPluginFile("dev-env", "src/index.ts"), + ); + + const stableAliases = buildPluginLoaderAliasMap(entry, undefined, undefined, "dist", null); + const devAliases = buildPluginLoaderAliasMap( + entry, + undefined, + undefined, + "dist", + devFixture.root, + ); + + expect(devAliases).not.toBe(stableAliases); + expect(fs.realpathSync(stableAliases["openclaw/plugin-sdk"] ?? "")).toBe( + fs.realpathSync(stableRootAlias), + ); + expect(fs.realpathSync(devAliases["openclaw/plugin-sdk"] ?? "")).toBe( + fs.realpathSync(devRootAlias), + ); + }); + it("does not reuse a public alias map after private qa aliases are enabled", () => { const fixture = createPluginSdkAliasFixture({ packageExports: { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 50cf871771f..d0de2405303 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { tryReadJsonSync } from "../infra/json-files.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { OPENCLAW_DEV_SOURCE_ROOT_ENV, resolveOpenClawDevSourceRoot } from "./dev-source-root.js"; +import { resolveOpenClawDevSourceRoot } from "./dev-source-root.js"; import { PluginLruCache } from "./plugin-cache-primitives.js"; type PluginSdkAliasCandidateKind = "dist" | "src"; @@ -16,6 +16,7 @@ export type LoaderModuleResolveParams = { argv1?: string; cwd?: string; moduleUrl?: string; + devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; }; @@ -333,10 +334,16 @@ function formatResolutionError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function resolveDevSourceRootParam(params: { devSourceRoot?: string | null }): string | null { + return params.devSourceRoot !== undefined + ? params.devSourceRoot + : resolveOpenClawDevSourceRoot(process.env); +} + function resolveLoaderPluginSdkPackageRoot( params: LoaderModuleResolveParams & { modulePath: string }, ): string | null { - const devSourceRoot = resolveOpenClawDevSourceRoot(process.env); + const devSourceRoot = resolveDevSourceRootParam(params); if (devSourceRoot) { return devSourceRoot; } @@ -382,6 +389,7 @@ export function listPluginSdkAliasCandidates(params: { argv1?: string; cwd?: string; moduleUrl?: string; + devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; }) { const orderedKinds = resolvePluginSdkAliasCandidateOrder({ @@ -423,6 +431,7 @@ export function resolvePluginSdkAliasFile(params: { argv1?: string; cwd?: string; moduleUrl?: string; + devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; }): string | null { try { @@ -434,6 +443,7 @@ export function resolvePluginSdkAliasFile(params: { argv1: params.argv1, cwd: params.cwd, moduleUrl: params.moduleUrl, + devSourceRoot: params.devSourceRoot, pluginSdkResolution: params.pluginSdkResolution, })) { if (fs.existsSync(candidate)) { @@ -1011,6 +1021,7 @@ function resolveBundledPluginPackagePublicSurfaceAliasMap(params: { argv1?: string; moduleUrl?: string; pluginSdkResolution: PluginSdkResolutionPreference; + devSourceRoot?: string | null; }): Record { const packageRoot = resolveLoaderPluginSdkPackageRoot(params); if (!packageRoot) { @@ -1072,6 +1083,7 @@ function resolveWorkspacePackageAliasMap(params: { argv1?: string; moduleUrl?: string; pluginSdkResolution: PluginSdkResolutionPreference; + devSourceRoot?: string | null; }): Record { const packageRoot = resolveLoaderPluginSdkPackageRoot(params); if (!packageRoot) { @@ -1207,6 +1219,21 @@ function listTrustedPrivatePluginSdkOwnerKeys(params: { ).map((owner) => owner.bundledPluginId); } +function resolvePrivatePluginSdkOwnerPackageRoot(params: { + modulePath: string; + argv1?: string; + moduleUrl?: string; + aliasPackageRoot: string; +}): string { + return ( + resolveLoaderPackageRoot({ + modulePath: params.modulePath, + argv1: params.argv1, + moduleUrl: params.moduleUrl, + }) ?? params.aliasPackageRoot + ); +} + function shouldIncludePrivateLocalOnlyPluginSdkSubpath(params: { packageRoot: string; modulePath: string; @@ -1250,12 +1277,16 @@ function listDistPluginSdkArtifactSubpaths(packageRoot: string): Set { function listPrivateLocalOnlyPluginSdkSubpaths(params: { packageRoot: string; + ownerPackageRoot: string; modulePath: string; }): string[] { return readPrivateLocalOnlyPluginSdkSubpaths(params.packageRoot).filter( (subpath) => - shouldIncludePrivateLocalOnlyPluginSdkSubpath({ ...params, subpath }) && - hasPluginSdkSubpathArtifact(params.packageRoot, subpath), + shouldIncludePrivateLocalOnlyPluginSdkSubpath({ + packageRoot: params.ownerPackageRoot, + modulePath: params.modulePath, + subpath, + }) && hasPluginSdkSubpathArtifact(params.packageRoot, subpath), ); } @@ -1264,6 +1295,7 @@ export function listPluginSdkExportedSubpaths( modulePath?: string; argv1?: string; moduleUrl?: string; + devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; } = {}, ): string[] { @@ -1272,11 +1304,21 @@ export function listPluginSdkExportedSubpaths( modulePath, argv1: params.argv1, moduleUrl: params.moduleUrl, + devSourceRoot: params.devSourceRoot, }); if (!packageRoot) { return []; } - const trustedPrivateOwners = listTrustedPrivatePluginSdkOwnerKeys({ packageRoot, modulePath }); + const ownerPackageRoot = resolvePrivatePluginSdkOwnerPackageRoot({ + modulePath, + argv1: params.argv1, + moduleUrl: params.moduleUrl, + aliasPackageRoot: packageRoot, + }); + const trustedPrivateOwners = listTrustedPrivatePluginSdkOwnerKeys({ + packageRoot: ownerPackageRoot, + modulePath, + }); const cacheKey = `${packageRoot}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}::privateOwners=${trustedPrivateOwners.join(",")}`; const cached = cachedPluginSdkExportedSubpaths.get(cacheKey); if (cached) { @@ -1285,7 +1327,7 @@ export function listPluginSdkExportedSubpaths( const subpaths = [ ...new Set([ ...(readPluginSdkSubpathsFromPackageRoot(packageRoot) ?? []), - ...listPrivateLocalOnlyPluginSdkSubpaths({ packageRoot, modulePath }), + ...listPrivateLocalOnlyPluginSdkSubpaths({ packageRoot, ownerPackageRoot, modulePath }), ]), ].toSorted(); cachedPluginSdkExportedSubpaths.set(cacheKey, subpaths); @@ -1297,6 +1339,7 @@ export function resolvePluginSdkScopedAliasMap( modulePath?: string; argv1?: string; moduleUrl?: string; + devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; } = {}, ): Record { @@ -1305,16 +1348,26 @@ export function resolvePluginSdkScopedAliasMap( modulePath, argv1: params.argv1, moduleUrl: params.moduleUrl, + devSourceRoot: params.devSourceRoot, }); if (!packageRoot) { return {}; } + const ownerPackageRoot = resolvePrivatePluginSdkOwnerPackageRoot({ + modulePath, + argv1: params.argv1, + moduleUrl: params.moduleUrl, + aliasPackageRoot: packageRoot, + }); const orderedKinds = resolvePluginSdkAliasCandidateOrder({ modulePath, isProduction: process.env.NODE_ENV === "production", pluginSdkResolution: params.pluginSdkResolution, }); - const trustedPrivateOwners = listTrustedPrivatePluginSdkOwnerKeys({ packageRoot, modulePath }); + const trustedPrivateOwners = listTrustedPrivatePluginSdkOwnerKeys({ + packageRoot: ownerPackageRoot, + modulePath, + }); const cacheKey = `${packageRoot}::${orderedKinds.join(",")}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}::privateOwners=${trustedPrivateOwners.join(",")}`; const cached = cachedPluginSdkScopedAliasMaps.get(cacheKey); if (cached) { @@ -1328,6 +1381,7 @@ export function resolvePluginSdkScopedAliasMap( modulePath, argv1: params.argv1, moduleUrl: params.moduleUrl, + devSourceRoot: params.devSourceRoot, pluginSdkResolution: params.pluginSdkResolution, })) { for (const kind of orderedKinds) { @@ -1366,7 +1420,8 @@ export function resolvePluginSdkScopedAliasMap( export function resolveExtensionApiAlias(params: LoaderModuleResolveParams = {}): string | null { try { const modulePath = resolveLoaderModulePath(params); - const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath }); + const packageRoot = + resolveDevSourceRootParam(params) ?? resolveLoaderPackageRoot({ ...params, modulePath }); if (!packageRoot) { return null; } @@ -1525,14 +1580,16 @@ function buildPluginLoaderAliasMapCacheKey(params: { argv1?: string; moduleUrl?: string; pluginSdkResolution: PluginSdkResolutionPreference; + devSourceRoot?: string | null; }) { + const devSourceRoot = resolveDevSourceRootParam(params); return [ params.modulePath, params.argv1 ?? "", params.moduleUrl ?? "", params.pluginSdkResolution, process.cwd(), - process.env[OPENCLAW_DEV_SOURCE_ROOT_ENV] ?? "", + devSourceRoot ?? "", process.env.NODE_ENV === "production" ? "production" : "non-production", shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "private-qa" : "public", ].join("\0"); @@ -1542,6 +1599,7 @@ function buildPluginLoaderModuleConfigCacheKey(params: { modulePath: string; argv1?: string; moduleUrl: string; + devSourceRoot?: string | null; preferBuiltDist?: boolean; pluginSdkResolution?: PluginSdkResolutionPreference; }) { @@ -1551,6 +1609,7 @@ function buildPluginLoaderModuleConfigCacheKey(params: { argv1: params.argv1, moduleUrl: params.moduleUrl, pluginSdkResolution: params.pluginSdkResolution ?? "auto", + devSourceRoot: params.devSourceRoot, }), params.preferBuiltDist === true ? "prefer-built-dist" : "default-dist", ].join("\0"); @@ -1561,12 +1620,14 @@ export function buildPluginLoaderAliasMap( argv1: string | undefined = STARTUP_ARGV1, moduleUrl?: string, pluginSdkResolution: PluginSdkResolutionPreference = "auto", + devSourceRoot?: string | null, ): Record { const cacheKey = buildPluginLoaderAliasMapCacheKey({ modulePath, argv1, moduleUrl, pluginSdkResolution, + devSourceRoot, }); const cached = aliasMapCache.get(cacheKey); if (cached) { @@ -1580,8 +1641,13 @@ export function buildPluginLoaderAliasMap( argv1, moduleUrl, pluginSdkResolution, + devSourceRoot, + }); + const extensionApiAlias = resolveExtensionApiAlias({ + modulePath, + pluginSdkResolution, + devSourceRoot, }); - const extensionApiAlias = resolveExtensionApiAlias({ modulePath, pluginSdkResolution }); const result: Record = { ...(extensionApiAlias ? { "openclaw/extension-api": normalizeJitiAliasTargetPath(extensionApiAlias) } @@ -1591,12 +1657,14 @@ export function buildPluginLoaderAliasMap( argv1, moduleUrl, pluginSdkResolution, + devSourceRoot, }), ...resolveWorkspacePackageAliasMap({ modulePath, argv1, moduleUrl, pluginSdkResolution, + devSourceRoot, }), ...(pluginSdkAlias ? Object.fromEntries( @@ -1608,7 +1676,13 @@ export function buildPluginLoaderAliasMap( : {}), ...Object.fromEntries( Object.entries( - resolvePluginSdkScopedAliasMap({ modulePath, argv1, moduleUrl, pluginSdkResolution }), + resolvePluginSdkScopedAliasMap({ + modulePath, + argv1, + moduleUrl, + pluginSdkResolution, + devSourceRoot, + }), ).map(([key, value]) => [key, normalizeJitiAliasTargetPath(value)]), ), }; @@ -1635,7 +1709,8 @@ export function resolvePluginRuntimeModulePathWithDiagnostics( isProduction: process.env.NODE_ENV === "production", pluginSdkResolution: params.pluginSdkResolution, }); - packageRoot = resolveLoaderPackageRoot({ ...params, modulePath }); + packageRoot = + resolveDevSourceRootParam(params) ?? resolveLoaderPackageRoot({ ...params, modulePath }); if (packageRoot) { appendPluginRuntimeModuleCandidates(candidates, packageRoot, orderedKinds); } else { @@ -1757,6 +1832,7 @@ export function resolvePluginLoaderModuleConfig(params: { modulePath: string; argv1?: string; moduleUrl: string; + devSourceRoot?: string | null; preferBuiltDist?: boolean; pluginSdkResolution?: PluginSdkResolutionPreference; }): { @@ -1779,6 +1855,7 @@ export function resolvePluginLoaderModuleConfig(params: { params.argv1, params.moduleUrl, params.pluginSdkResolution, + params.devSourceRoot, ); const result = { tryNative,