diff --git a/scripts/e2e/parallels-npm-update-smoke.sh b/scripts/e2e/parallels-npm-update-smoke.sh index 61c73eabb56..6c55dd58793 100755 --- a/scripts/e2e/parallels-npm-update-smoke.sh +++ b/scripts/e2e/parallels-npm-update-smoke.sh @@ -718,7 +718,17 @@ function Invoke-OpenClawUpdateWithTimeout { $updateJob = Start-Job -ScriptBlock { param([string]$Path, [string]$Target) - $output = & $Path update --tag $Target --yes --json *>&1 + $previousDisableBundledPlugins = $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS + $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1' + try { + $output = & $Path update --tag $Target --yes --json *>&1 + } finally { + if ($null -eq $previousDisableBundledPlugins) { + Remove-Item Env:OPENCLAW_DISABLE_BUNDLED_PLUGINS -ErrorAction SilentlyContinue + } else { + $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = $previousDisableBundledPlugins + } + } [pscustomobject]@{ ExitCode = $LASTEXITCODE Output = ($output | Out-String).Trim() @@ -1649,7 +1659,7 @@ stop_openclaw_gateway_processes() { # host can observe new plugin metadata mid-update and abort config validation. scrub_future_plugin_entries stop_openclaw_gateway_processes -/opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json +OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json # Same-guest npm upgrades can leave the old gateway process holding the old # bundled plugin host version. Stop it before post-update config commands. stop_openclaw_gateway_processes @@ -1782,7 +1792,7 @@ stop_openclaw_gateway_processes() { # the old host can observe new plugin metadata mid-update and abort validation. scrub_future_plugin_entries stop_openclaw_gateway_processes -openclaw update --tag "$update_target" --yes --json +OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag "$update_target" --yes --json # The fresh Linux lane starts a manual gateway; stop the old process before # post-update config validation sees mixed old-host/new-plugin metadata. stop_openclaw_gateway_processes diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 32e59f67306..deb3a963486 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; +import { Module } from "node:module"; import os from "node:os"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; @@ -56,6 +57,8 @@ const BUNDLED_RUNTIME_DEPS_LOCK_TIMEOUT_MS = 5 * 60_000; const BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS = 10 * 60_000; const BUNDLED_RUNTIME_DEPS_OWNERLESS_LOCK_STALE_MS = 30_000; +const registeredBundledRuntimeDepNodePaths = new Set(); + export type BundledRuntimeDepsNpmRunner = { command: string; args: string[]; @@ -440,6 +443,43 @@ function resolveBundledPluginPackageRoot(pluginRoot: string): string | null { return path.dirname(buildDir); } +export function resolveBundledRuntimeDependencyPackageRoot(pluginRoot: string): string | null { + return resolveBundledPluginPackageRoot(pluginRoot); +} + +export function registerBundledRuntimeDependencyNodePath(rootDir: string): void { + const nodeModulesDir = path.join(rootDir, "node_modules"); + if (registeredBundledRuntimeDepNodePaths.has(nodeModulesDir) || !fs.existsSync(nodeModulesDir)) { + return; + } + const currentPaths = (process.env.NODE_PATH ?? "") + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + process.env.NODE_PATH = [ + nodeModulesDir, + ...currentPaths.filter((entry) => entry !== nodeModulesDir), + ].join(path.delimiter); + (Module as unknown as { _initPaths?: () => void })._initPaths?.(); + registeredBundledRuntimeDepNodePaths.add(nodeModulesDir); +} + +export function clearBundledRuntimeDependencyNodePaths(): void { + if (registeredBundledRuntimeDepNodePaths.size === 0) { + return; + } + const retainedPaths = (process.env.NODE_PATH ?? "") + .split(path.delimiter) + .filter((entry) => entry.length > 0 && !registeredBundledRuntimeDepNodePaths.has(entry)); + if (retainedPaths.length > 0) { + process.env.NODE_PATH = retainedPaths.join(path.delimiter); + } else { + delete process.env.NODE_PATH; + } + registeredBundledRuntimeDepNodePaths.clear(); + (Module as unknown as { _initPaths?: () => void })._initPaths?.(); +} + function isPackagedBundledPluginRoot(pluginRoot: string): boolean { const packageRoot = resolveBundledPluginPackageRoot(pluginRoot); return Boolean(packageRoot && !isSourceCheckoutRoot(packageRoot)); diff --git a/src/plugins/bundled-runtime-root.ts b/src/plugins/bundled-runtime-root.ts index 11dee2d7bae..f1f1cc7260d 100644 --- a/src/plugins/bundled-runtime-root.ts +++ b/src/plugins/bundled-runtime-root.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { ensureBundledPluginRuntimeDeps, resolveBundledRuntimeDependencyInstallRoot, + resolveBundledRuntimeDependencyPackageRoot, + registerBundledRuntimeDependencyNodePath, } from "./bundled-runtime-deps.js"; const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map(); @@ -44,6 +46,11 @@ export function prepareBundledPluginRuntimeRoot(params: { if (path.resolve(installRoot) === path.resolve(params.pluginRoot)) { return { pluginRoot: params.pluginRoot, modulePath: params.modulePath }; } + const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot); + if (packageRoot) { + registerBundledRuntimeDependencyNodePath(packageRoot); + } + registerBundledRuntimeDependencyNodePath(installRoot); const mirrorRoot = mirrorBundledPluginRuntimeRoot({ pluginId: params.pluginId, pluginRoot: params.pluginRoot, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 69f6cd23cfd..0358bb118e8 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1558,6 +1558,106 @@ module.exports = { expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); }); + it("loads copied external runtime mirrors with package-root runtime deps", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + const bundledDir = path.join(packageRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledDir, "alpha"); + const packageDepRoot = path.join(packageRoot, "node_modules", "root-support"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.mkdirSync(packageDepRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.24", + type: "module", + dependencies: { "root-support": "1.0.0" }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(packageDepRoot, "package.json"), + JSON.stringify({ + name: "root-support", + version: "1.0.0", + type: "module", + exports: "./index.js", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(packageDepRoot, "index.js"), + "export default { marker: 'root-ok' };\n", + "utf-8", + ); + fs.writeFileSync( + path.join(packageRoot, "dist", "manifest-support.js"), + [`import support from "root-support";`, `export const marker = support.marker;`, ""].join( + "\n", + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "index.js"), + [ + `import { marker } from "../../manifest-support.js";`, + `export default {`, + ` id: "alpha",`, + ` register(api) {`, + ` api.registerCommand({ name: "root-support", handler: () => marker });`, + ` },`, + `};`, + "", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/alpha", + version: "1.0.0", + type: "module", + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "alpha", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; + + const symlinkSync = vi.spyOn(fs, "symlinkSync").mockImplementation(() => { + throw Object.assign(new Error("symlinks unavailable"), { code: "EPERM" }); + }); + let registry: PluginRegistry | null = null; + try { + registry = loadOpenClawPlugins({ + cache: false, + config: { plugins: { enabled: true } }, + }); + } finally { + symlinkSync.mockRestore(); + } + + 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 5ce44c1c2db..70772972258 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; -import { Module } from "node:module"; import path from "node:path"; import { clearAgentHarnesses, @@ -33,9 +32,12 @@ import { resolvePluginActivationSourceConfig } from "./activation-source-config. import { buildPluginApi } from "./api-builder.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { + clearBundledRuntimeDependencyNodePaths, ensureBundledPluginRuntimeDeps, installBundledRuntimeDeps, resolveBundledRuntimeDependencyInstallRoot, + resolveBundledRuntimeDependencyPackageRoot, + registerBundledRuntimeDependencyNodePath, type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps.js"; import { @@ -255,6 +257,7 @@ export function clearPluginLoaderCache(): void { inFlightPluginRegistryLoads.clear(); openAllowlistWarningCache.clear(); clearBundledRuntimeDependencyNodePaths(); + registeredBundledRuntimeDepMirrorRoots.clear(); clearAgentHarnesses(); clearPluginCommands(); clearCompactionProviders(); @@ -484,12 +487,11 @@ function resolveCanonicalDistRuntimeSource(source: string): string { return fs.existsSync(candidate) ? candidate : source; } -const registeredBundledRuntimeDepNodePaths = new Set(); +const registeredBundledRuntimeDepMirrorRoots = new Set(); function isBundledRuntimeDependencyMirrorPath(modulePath: string): boolean { const resolvedModulePath = path.resolve(modulePath); - for (const nodeModulesDir of registeredBundledRuntimeDepNodePaths) { - const installRoot = path.dirname(nodeModulesDir); + for (const installRoot of registeredBundledRuntimeDepMirrorRoots) { if ( resolvedModulePath === installRoot || resolvedModulePath.startsWith(`${installRoot}${path.sep}`) @@ -500,37 +502,8 @@ function isBundledRuntimeDependencyMirrorPath(modulePath: string): boolean { return false; } -function registerBundledRuntimeDependencyNodePath(installRoot: string): void { - const nodeModulesDir = path.join(installRoot, "node_modules"); - if (registeredBundledRuntimeDepNodePaths.has(nodeModulesDir) || !fs.existsSync(nodeModulesDir)) { - return; - } - const currentPaths = (process.env.NODE_PATH ?? "") - .split(path.delimiter) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - process.env.NODE_PATH = [ - nodeModulesDir, - ...currentPaths.filter((entry) => entry !== nodeModulesDir), - ].join(path.delimiter); - (Module as unknown as { _initPaths?: () => void })._initPaths?.(); - registeredBundledRuntimeDepNodePaths.add(nodeModulesDir); -} - -function clearBundledRuntimeDependencyNodePaths(): void { - if (registeredBundledRuntimeDepNodePaths.size === 0) { - return; - } - const retainedPaths = (process.env.NODE_PATH ?? "") - .split(path.delimiter) - .filter((entry) => entry.length > 0 && !registeredBundledRuntimeDepNodePaths.has(entry)); - if (retainedPaths.length > 0) { - process.env.NODE_PATH = retainedPaths.join(path.delimiter); - } else { - delete process.env.NODE_PATH; - } - registeredBundledRuntimeDepNodePaths.clear(); - (Module as unknown as { _initPaths?: () => void })._initPaths?.(); +function registerBundledRuntimeDependencyMirrorRoot(installRoot: string): void { + registeredBundledRuntimeDepMirrorRoots.add(path.resolve(installRoot)); } function mirrorBundledPluginRuntimeRoot(params: { @@ -2382,7 +2355,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } } if (path.resolve(installRoot) !== path.resolve(pluginRoot)) { + const packageRoot = resolveBundledRuntimeDependencyPackageRoot(pluginRoot); + if (packageRoot) { + registerBundledRuntimeDependencyNodePath(packageRoot); + } registerBundledRuntimeDependencyNodePath(installRoot); + registerBundledRuntimeDependencyMirrorRoot(installRoot); runtimePluginRoot = mirrorBundledPluginRuntimeRoot({ pluginId: record.id, pluginRoot, diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 21ea93d2244..a9905b396e9 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -21,11 +21,11 @@ describe("parallels npm update smoke", () => { expect(script).toContain("delete entries.whatsapp"); expect(script).toContain("Remove-FuturePluginEntries\n Stop-OpenClawGatewayProcesses"); expect(script).toContain("scrub_future_plugin_entries\nstop_openclaw_gateway_processes"); - expect(script).not.toContain("$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'\n & $Path update"); - expect(script).not.toContain( + expect(script).toContain("$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'"); + expect(script).toContain( "OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update", ); - expect(script).not.toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update"); + expect(script).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update"); expect(script).toContain( "OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop", );