diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c49bcb501..3b3dd62903b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/runtime-deps: memoize packaged bundled runtime dist-mirror preparation after the first successful pass while keeping source-checkout mirrors refreshable, so constrained Docker/VPS installs avoid repeated root scans before chat turns. Refs #73428, #73421, #73532, and #73477. Thanks @Dimaoggg, @oromeis, @oadiazp, @jmfraga, @bstanbury, @antoniusfelix, and @jkobject. - Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie. - Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev. - Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest. diff --git a/src/plugins/bundled-runtime-dist-mirror-cache.ts b/src/plugins/bundled-runtime-dist-mirror-cache.ts new file mode 100644 index 00000000000..5a70c856eaa --- /dev/null +++ b/src/plugins/bundled-runtime-dist-mirror-cache.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; + +const preparedBundledRuntimeDistMirrors = new Set(); + +export function clearBundledRuntimeDistMirrorPreparationCache(): void { + preparedBundledRuntimeDistMirrors.clear(); +} + +export function shouldReusePreparedBundledRuntimeDistMirror(params: { + sourceDistRoot: string; + mirrorDistRoot: string; +}): boolean { + if (isSourceCheckoutDistRoot(params.sourceDistRoot)) { + return false; + } + if (!preparedBundledRuntimeDistMirrors.has(bundledRuntimeDistMirrorCacheKey(params))) { + return false; + } + return ( + fs.existsSync(params.mirrorDistRoot) && + fs.existsSync(path.join(params.mirrorDistRoot, "extensions")) && + fs.existsSync(path.join(params.mirrorDistRoot, "package.json")) + ); +} + +export function markBundledRuntimeDistMirrorPrepared(params: { + sourceDistRoot: string; + mirrorDistRoot: string; +}): void { + if (isSourceCheckoutDistRoot(params.sourceDistRoot)) { + return; + } + preparedBundledRuntimeDistMirrors.add(bundledRuntimeDistMirrorCacheKey(params)); +} + +function bundledRuntimeDistMirrorCacheKey(params: { + sourceDistRoot: string; + mirrorDistRoot: string; +}): string { + return `${path.resolve(params.sourceDistRoot)}\0${path.resolve(params.mirrorDistRoot)}`; +} + +function isSourceCheckoutDistRoot(sourceDistRoot: string): boolean { + const packageRoot = path.dirname(sourceDistRoot); + return ( + (fs.existsSync(path.join(packageRoot, ".git")) || + fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml"))) && + fs.existsSync(path.join(packageRoot, "src")) && + fs.existsSync(path.join(packageRoot, "extensions")) + ); +} diff --git a/src/plugins/bundled-runtime-root.test.ts b/src/plugins/bundled-runtime-root.test.ts index d71202455b2..a07b547a374 100644 --- a/src/plugins/bundled-runtime-root.test.ts +++ b/src/plugins/bundled-runtime-root.test.ts @@ -198,7 +198,9 @@ describe("prepareBundledPluginRuntimeRoot", () => { } const realReadFileSync = fs.readFileSync.bind(fs); + const realReaddirSync = fs.readdirSync.bind(fs); const readPaths: string[] = []; + const readdirPaths: string[] = []; vi.spyOn(fs, "readFileSync").mockImplementation(((target, options) => { const targetPath = target.toString(); if (targetPath === rootChunk || targetPath === externalChunk) { @@ -206,6 +208,16 @@ describe("prepareBundledPluginRuntimeRoot", () => { } return realReadFileSync(target, options as never); }) as typeof fs.readFileSync); + vi.spyOn(fs, "readdirSync").mockImplementation(((target, options) => { + const targetPath = target.toString(); + if ( + targetPath === path.join(packageRoot, "dist") && + new Error().stack?.includes("mirrorBundledRuntimeDistRootEntries") + ) { + readdirPaths.push(targetPath); + } + return realReaddirSync(target, options as never); + }) as typeof fs.readdirSync); for (const pluginId of ["alpha", "beta"]) { const pluginRoot = path.join(packageRoot, "dist", "extensions", pluginId); @@ -219,6 +231,75 @@ describe("prepareBundledPluginRuntimeRoot", () => { expect(readPaths.filter((entry) => entry === rootChunk)).toHaveLength(1); expect(readPaths.filter((entry) => entry === externalChunk)).toHaveLength(1); + expect(readdirPaths).toHaveLength(1); + }); + + it("does not memoize source-checkout dist mirrors", () => { + const packageRoot = makeTempRoot(); + const stageDir = makeTempRoot(); + const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; + fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true }); + const pluginRoot = path.join(packageRoot, "dist", "extensions", "alpha"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), + "utf8", + ); + fs.writeFileSync(path.join(packageRoot, "dist", "shared-runtime.js"), "export {};\n", "utf8"); + fs.writeFileSync( + path.join(pluginRoot, "index.js"), + `import "../../shared-runtime.js"; export default { id: "alpha" };\n`, + "utf8", + ); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/alpha", + version: "1.0.0", + type: "module", + dependencies: { "alpha-runtime": "1.0.0" }, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + ), + "utf8", + ); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); + fs.mkdirSync(path.join(installRoot, "node_modules", "alpha-runtime"), { recursive: true }); + fs.writeFileSync( + path.join(installRoot, "node_modules", "alpha-runtime", "package.json"), + JSON.stringify({ name: "alpha-runtime", version: "1.0.0", type: "module" }), + "utf8", + ); + + const realReaddirSync = fs.readdirSync.bind(fs); + const readdirPaths: string[] = []; + vi.spyOn(fs, "readdirSync").mockImplementation(((target, options) => { + const targetPath = target.toString(); + if ( + targetPath === path.join(packageRoot, "dist") && + new Error().stack?.includes("mirrorBundledRuntimeDistRootEntries") + ) { + readdirPaths.push(targetPath); + } + return realReaddirSync(target, options as never); + }) as typeof fs.readdirSync); + + for (let index = 0; index < 2; index += 1) { + prepareBundledPluginRuntimeRoot({ + pluginId: "alpha", + pluginRoot, + modulePath: path.join(pluginRoot, "index.js"), + env, + }); + } + + expect(readdirPaths).toHaveLength(2); }); it("does not copy staged runtime mirror dist files onto themselves", () => { diff --git a/src/plugins/bundled-runtime-root.ts b/src/plugins/bundled-runtime-root.ts index e29aac5b4da..5e90d010f17 100644 --- a/src/plugins/bundled-runtime-root.ts +++ b/src/plugins/bundled-runtime-root.ts @@ -9,6 +9,10 @@ import { shouldMaterializeBundledRuntimeMirrorDistFile, withBundledRuntimeDepsFilesystemLock, } from "./bundled-runtime-deps.js"; +import { + markBundledRuntimeDistMirrorPrepared, + shouldReusePreparedBundledRuntimeDistMirror, +} from "./bundled-runtime-dist-mirror-cache.js"; import { copyBundledPluginRuntimeRoot, precomputeBundledRuntimeMirrorMetadata, @@ -162,10 +166,13 @@ function prepareBundledPluginRuntimeDistMirror(params: { ensureBundledRuntimeMirrorDirectory(mirrorDistRoot); fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 }); ensureBundledRuntimeDistPackageJson(mirrorDistRoot); - mirrorBundledRuntimeDistRootEntries({ - sourceDistRoot, - mirrorDistRoot, - }); + if (!shouldReusePreparedBundledRuntimeDistMirror({ sourceDistRoot, mirrorDistRoot })) { + mirrorBundledRuntimeDistRootEntries({ + sourceDistRoot, + mirrorDistRoot, + }); + markBundledRuntimeDistMirrorPrepared({ sourceDistRoot, mirrorDistRoot }); + } if (sourceDistRootName === "dist-runtime") { mirrorCanonicalBundledRuntimeDistRoot({ installRoot: params.installRoot, @@ -242,10 +249,21 @@ function mirrorCanonicalBundledRuntimeDistRoot(params: { ensureBundledRuntimeMirrorDirectory(targetCanonicalDistRoot); fs.mkdirSync(path.join(targetCanonicalDistRoot, "extensions"), { recursive: true, mode: 0o755 }); ensureBundledRuntimeDistPackageJson(targetCanonicalDistRoot); - mirrorBundledRuntimeDistRootEntries({ - sourceDistRoot: sourceCanonicalDistRoot, - mirrorDistRoot: targetCanonicalDistRoot, - }); + if ( + !shouldReusePreparedBundledRuntimeDistMirror({ + sourceDistRoot: sourceCanonicalDistRoot, + mirrorDistRoot: targetCanonicalDistRoot, + }) + ) { + mirrorBundledRuntimeDistRootEntries({ + sourceDistRoot: sourceCanonicalDistRoot, + mirrorDistRoot: targetCanonicalDistRoot, + }); + markBundledRuntimeDistMirrorPrepared({ + sourceDistRoot: sourceCanonicalDistRoot, + mirrorDistRoot: targetCanonicalDistRoot, + }); + } ensureOpenClawPluginSdkAlias(targetCanonicalDistRoot); const pluginId = path.basename(params.pluginRoot); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 2e738334e82..8d0696f5ac0 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -43,6 +43,11 @@ import { withBundledRuntimeDepsFilesystemLock, type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps.js"; +import { + clearBundledRuntimeDistMirrorPreparationCache, + markBundledRuntimeDistMirrorPrepared, + shouldReusePreparedBundledRuntimeDistMirror, +} from "./bundled-runtime-dist-mirror-cache.js"; import { copyBundledPluginRuntimeRoot, precomputeBundledRuntimeMirrorMetadata, @@ -114,8 +119,8 @@ import { normalizePluginIdScope, serializePluginIdScope, } from "./plugin-scope.js"; -import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; import { getActivePluginRegistry, @@ -280,6 +285,7 @@ function createPluginCandidatesFromManifestRegistry( export function clearPluginLoaderCache(): void { pluginLoaderCacheState.clear(); clearBundledRuntimeDependencyNodePaths(); + clearBundledRuntimeDistMirrorPreparationCache(); bundledRuntimeDependencyJitiAliases.clear(); clearAgentHarnesses(); clearPluginCommands(); @@ -770,10 +776,13 @@ function prepareBundledPluginRuntimeDistMirror(params: { ensureBundledRuntimeMirrorDirectory(mirrorDistRoot); fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 }); ensureBundledRuntimeDistPackageJson(mirrorDistRoot); - mirrorBundledRuntimeDistRootEntries({ - sourceDistRoot, - mirrorDistRoot, - }); + if (!shouldReusePreparedBundledRuntimeDistMirror({ sourceDistRoot, mirrorDistRoot })) { + mirrorBundledRuntimeDistRootEntries({ + sourceDistRoot, + mirrorDistRoot, + }); + markBundledRuntimeDistMirrorPrepared({ sourceDistRoot, mirrorDistRoot }); + } if (sourceDistRootName === "dist-runtime") { mirrorCanonicalBundledRuntimeDistRoot({ installRoot: params.installRoot, @@ -850,10 +859,21 @@ function mirrorCanonicalBundledRuntimeDistRoot(params: { ensureBundledRuntimeMirrorDirectory(targetCanonicalDistRoot); fs.mkdirSync(path.join(targetCanonicalDistRoot, "extensions"), { recursive: true, mode: 0o755 }); ensureBundledRuntimeDistPackageJson(targetCanonicalDistRoot); - mirrorBundledRuntimeDistRootEntries({ - sourceDistRoot: sourceCanonicalDistRoot, - mirrorDistRoot: targetCanonicalDistRoot, - }); + if ( + !shouldReusePreparedBundledRuntimeDistMirror({ + sourceDistRoot: sourceCanonicalDistRoot, + mirrorDistRoot: targetCanonicalDistRoot, + }) + ) { + mirrorBundledRuntimeDistRootEntries({ + sourceDistRoot: sourceCanonicalDistRoot, + mirrorDistRoot: targetCanonicalDistRoot, + }); + markBundledRuntimeDistMirrorPrepared({ + sourceDistRoot: sourceCanonicalDistRoot, + mirrorDistRoot: targetCanonicalDistRoot, + }); + } ensureOpenClawPluginSdkAlias(targetCanonicalDistRoot); const pluginId = path.basename(params.pluginRoot); @@ -2243,12 +2263,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi clearPluginInteractiveHandlers(); clearDetachedTaskLifecycleRuntimeRegistration(); clearMemoryPluginState(); - activatePluginRegistry( - emptyRegistry, - cacheKey, - runtimeSubagentMode, - options.workspaceDir, - ); + activatePluginRegistry(emptyRegistry, cacheKey, runtimeSubagentMode, options.workspaceDir); } return emptyRegistry; }