diff --git a/scripts/release-check.ts b/scripts/release-check.ts index e5f22b0aa45..3a239eb720a 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, mkdirSync, + realpathSync, readdirSync, readFileSync, rmSync, @@ -17,6 +18,10 @@ import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "../src/infra/package-dist-inventory.ts"; +import { + resolveBundledRuntimeDependencyInstallRoot, + resolveBundledRuntimeDependencyPackageInstallRoot, +} from "../src/plugins/bundled-runtime-deps.ts"; import { collectBundledExtensionManifestErrors, type BundledExtension, @@ -317,28 +322,48 @@ function bundledRuntimeDependencySentinelPath( ); } -function bundledRuntimeDependencySentinelCandidates( +export function bundledRuntimeDependencySentinelCandidates( packageRoot: string, pluginId: string, dependencyName: string, + env: NodeJS.ProcessEnv = process.env, ): string[] { const dependencyParts = dependencyName.split("/"); + const packageRoots = [ + packageRoot, + (() => { + try { + return realpathSync(packageRoot); + } catch { + return packageRoot; + } + })(), + ]; + const runtimeRoots = packageRoots.flatMap((root) => [ + resolveBundledRuntimeDependencyPackageInstallRoot(root, { env }), + resolveBundledRuntimeDependencyInstallRoot(join(root, "dist", "extensions", pluginId), { + env, + }), + ]); return [ bundledRuntimeDependencySentinelPath(packageRoot, pluginId, dependencyName), join(packageRoot, "dist", "extensions", "node_modules", ...dependencyParts, "package.json"), join(packageRoot, "node_modules", ...dependencyParts, "package.json"), - ]; + ...runtimeRoots.map((root) => join(root, "node_modules", ...dependencyParts, "package.json")), + ].filter((candidate, index, candidates) => candidates.indexOf(candidate) === index); } function assertBundledRuntimeDependencyAbsent(params: { packageRoot: string; pluginId: string; dependencyName: string; + env?: NodeJS.ProcessEnv; }): void { const sentinelPath = bundledRuntimeDependencySentinelCandidates( params.packageRoot, params.pluginId, params.dependencyName, + params.env, ).find((candidate) => existsSync(candidate)); if (sentinelPath) { throw new Error( @@ -351,11 +376,13 @@ function assertBundledRuntimeDependencyPresent(params: { packageRoot: string; pluginId: string; dependencyName: string; + env?: NodeJS.ProcessEnv; }): void { const sentinelPath = bundledRuntimeDependencySentinelCandidates( params.packageRoot, params.pluginId, params.dependencyName, + params.env, ).find((candidate) => existsSync(candidate)); if (sentinelPath) { return; @@ -413,24 +440,25 @@ function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: str { pluginId: "feishu", dependencyName: "@larksuiteoapi/node-sdk" }, ] as const; - for (const dep of lazyDeps) { - assertBundledRuntimeDependencyAbsent({ packageRoot, ...dep }); - } - const homeDir = join(tmpRoot, "activation-home"); mkdirSync(homeDir, { recursive: true }); + const env = createPackedCliSmokeEnv(process.env, { + HOME: homeDir, + OPENAI_API_KEY: "sk-openclaw-release-check", + }); + for (const dep of lazyDeps) { + assertBundledRuntimeDependencyAbsent({ packageRoot, env, ...dep }); + } + writePackedBundledPluginActivationConfig(homeDir); execFileSync(process.execPath, [join(packageRoot, "openclaw.mjs"), "plugins", "doctor"], { cwd: packageRoot, stdio: "inherit", - env: createPackedCliSmokeEnv(process.env, { - HOME: homeDir, - OPENAI_API_KEY: "sk-openclaw-release-check", - }), + env, }); for (const dep of lazyDeps) { - assertBundledRuntimeDependencyPresent({ packageRoot, ...dep }); + assertBundledRuntimeDependencyPresent({ packageRoot, env, ...dep }); } } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index ab49fce0bd6..6aefb81e3bc 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1504,6 +1504,60 @@ module.exports = { expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); }); + it("loads bundled plugins from symlinked package roots with an external stage dir", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + const aliasRoot = path.join(makeTempDir(), "openclaw-alias"); + const bundledDir = path.join(packageRoot, "dist", "extensions"); + const plugin = writePlugin({ + id: "alpha", + dir: path.join(bundledDir, "alpha"), + filename: "index.cjs", + body: `module.exports = { id: "alpha", register(api) { api.registerCommand({ name: "alpha", handler: () => "ok" }); } };`, + }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.24", type: "module" }), + "utf-8", + ); + fs.writeFileSync( + path.join(plugin.dir, "package.json"), + JSON.stringify( + { + name: "@openclaw/alpha", + version: "1.0.0", + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "alpha", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + fs.symlinkSync(packageRoot, aliasRoot, "dir"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(aliasRoot, "dist", "extensions"); + process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { plugins: { enabled: true } }, + }); + + expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); + }); + it("loads bundled plugins with plugin-sdk imports from an external stage dir", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 0d34e3e92c4..def16bb42e9 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2276,8 +2276,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }; const pluginRoot = safeRealpathOrResolve(candidate.rootDir); let runtimePluginRoot = pluginRoot; - let runtimeCandidateSource = candidate.source; - let runtimeSetupSource = manifestRecord.setupSource; + let runtimeCandidateSource = + candidate.origin === "bundled" ? safeRealpathOrResolve(candidate.source) : candidate.source; + let runtimeSetupSource = + candidate.origin === "bundled" && manifestRecord.setupSource + ? safeRealpathOrResolve(manifestRecord.setupSource) + : manifestRecord.setupSource; const scopedSetupOnlyChannelPluginRequested = includeSetupOnlyChannelPlugins && @@ -2381,12 +2385,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }); runtimeCandidateSource = remapBundledPluginRuntimePath({ - source: candidate.source, + source: runtimeCandidateSource, pluginRoot, mirroredRoot: runtimePluginRoot, - }) ?? candidate.source; + }) ?? runtimeCandidateSource; runtimeSetupSource = remapBundledPluginRuntimePath({ - source: manifestRecord.setupSource, + source: runtimeSetupSource, pluginRoot, mirroredRoot: runtimePluginRoot, }); @@ -3186,7 +3190,11 @@ export async function loadOpenClawPluginCliRegistry( const pluginRoot = safeRealpathOrResolve(candidate.rootDir); const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir); const sourceForCliMetadata = - candidate.origin === "bundled" ? cliMetadataSource : (cliMetadataSource ?? candidate.source); + candidate.origin === "bundled" + ? cliMetadataSource + ? safeRealpathOrResolve(cliMetadataSource) + : safeRealpathOrResolve(candidate.source) + : (cliMetadataSource ?? candidate.source); if (!sourceForCliMetadata) { record.status = "loaded"; registry.plugins.push(record); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index c147ca872a3..fb12e545106 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -12,6 +12,7 @@ import { collectBundledPluginRootRuntimeMirrorErrors, collectForbiddenPackContentPaths, collectInstalledBundledPluginRuntimeDepErrors, + bundledRuntimeDependencySentinelCandidates, collectRootDistBundledRuntimeMirrors, collectForbiddenPackPaths, collectMissingPackPaths, @@ -673,3 +674,36 @@ describe("collectInstalledBundledPluginRuntimeDepErrors", () => { } }); }); + +describe("bundledRuntimeDependencySentinelCandidates", () => { + it("checks canonical external runtime-deps roots for packed installs", () => { + const root = mkdtempSync(join(tmpdir(), "release-check-runtime-candidates-")); + const packageRoot = join(root, "package"); + const aliasRoot = join(root, "package-alias"); + const homeRoot = join(root, "home"); + try { + mkdirSync(join(packageRoot, "dist", "extensions", "browser"), { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.24-beta.1" }, null, 2), + ); + symlinkSync(packageRoot, aliasRoot, "dir"); + + const candidates = bundledRuntimeDependencySentinelCandidates( + aliasRoot, + "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")), + ); + + expect(externalCandidates.length).toBeGreaterThanOrEqual(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});