From 03bfdbb052d21415f54f2c797131f54382c81b4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 21:07:18 +0100 Subject: [PATCH] fix: stage mirrored bundled runtime deps --- CHANGELOG.md | 1 + src/plugins/bundled-runtime-deps.test.ts | 73 +++++++++ src/plugins/bundled-runtime-deps.ts | 197 +++++++++++++++++++++-- 3 files changed, 261 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d133f977e5..b9f2ea182ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Channels/sessions: skip last-route writes when inbound session recording explicitly disables creation, so plugin-owned guarded inbound paths cannot create route-only phantom sessions. Carries forward #73009. Thanks @jzakirov. +- Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03. - Channels/Telegram: skip the optional webhook-info API call during polling-mode status checks and startup bot-label probes so long-polling setups avoid an unnecessary Telegram round trip. Carries forward #72990. Thanks @danielgruneberg. - CLI/message: resolve targeted `openclaw message` channels to their owning plugin before loading the registry, and fall back to configured channel plugins when the channel must be inferred, so scripted sends avoid full bundled plugin registry scans without assuming channel ids match plugin ids. Fixes #73006. Thanks @jasonftl. - CLI/models: keep route-first `models status --json` stdout reserved for the JSON payload by routing auth-profile and startup diagnostics to stderr. Fixes #72962. Thanks @vishutdhar. diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index dc7c49f8232..2edbad7e16e 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -999,6 +999,79 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { ]); }); + it("reports missing root-dist mirror deps for selected bundled plugins", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.25", + dependencies: { chokidar: "^5.0.0" }, + }), + ); + writeBundledPluginPackage({ + packageRoot, + pluginId: "memory-core", + deps: { chokidar: "^5.0.0" }, + enabledByDefault: true, + }); + fs.writeFileSync( + path.join(packageRoot, "dist", "refresh-CZ2n5WoB.js"), + `import chokidar from "chokidar";\n`, + ); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot, + config: {}, + env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, + }); + + expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["chokidar@^5.0.0"]); + expect(result.deps[0]?.pluginIds).toEqual(["memory-core"]); + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["chokidar@^5.0.0"]); + }); + + it("does not report root-dist mirror deps for inactive bundled plugin owners", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.25", + dependencies: { chokidar: "^5.0.0" }, + }), + ); + writeBundledPluginPackage({ + packageRoot, + pluginId: "memory-core", + deps: { chokidar: "^5.0.0" }, + }); + writeBundledPluginPackage({ + packageRoot, + pluginId: "slack", + deps: {}, + channels: ["slack"], + }); + fs.writeFileSync( + path.join(packageRoot, "dist", "refresh-CZ2n5WoB.js"), + `import chokidar from "chokidar";\n`, + ); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot, + selectedPluginIds: ["slack"], + config: { + channels: { slack: { botToken: "xoxb-token" } }, + }, + env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, + }); + + expect(result.deps).toEqual([]); + expect(result.missing).toEqual([]); + }); + it("reports missing mirrored core runtime deps for startup plugins without own deps", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index c53aa6c75b7..26e6e7cacea 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -69,7 +69,7 @@ const BUNDLED_RUNTIME_DEPS_OWNERLESS_LOCK_STALE_MS = 30_000; const BUNDLED_RUNTIME_DEPS_INSTALL_PROGRESS_INTERVAL_MS = 5_000; const BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS = new Set([".cjs", ".js", ".mjs"]); const BUNDLED_RUNTIME_MIRROR_PLUGIN_REGION_RE = /(?:^|\n)\/\/#region extensions\/[^/\s]+(?:\/|$)/u; -const MIRRORED_PACKAGE_RUNTIME_DEP_NAMES = ["tslog"] as const; +const MIRRORED_CORE_RUNTIME_DEP_NAMES = ["tslog"] as const; const MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID = "openclaw-core"; const registeredBundledRuntimeDepNodePaths = new Set(); @@ -464,9 +464,13 @@ function collectRuntimeDeps(packageJson: JsonObject): Record { }; } -function collectMirroredPackageRuntimeDeps(packageRoot: string | null): { +function collectMirroredPackageRuntimeDeps( + packageRoot: string | null, + ownerPluginIds?: ReadonlySet, +): { name: string; version: string; + pluginIds: string[]; }[] { if (!packageRoot) { return []; @@ -476,9 +480,186 @@ function collectMirroredPackageRuntimeDeps(packageRoot: string | null): { return []; } const runtimeDeps = collectRuntimeDeps(packageJson); - return MIRRORED_PACKAGE_RUNTIME_DEP_NAMES.flatMap((name) => { + const coreRuntimeDeps = MIRRORED_CORE_RUNTIME_DEP_NAMES.flatMap((name) => { const dep = parseInstallableRuntimeDep(name, runtimeDeps[name]); - return dep ? [dep] : []; + return dep ? [{ ...dep, pluginIds: [MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID] }] : []; + }); + return mergeRuntimeDepEntries([ + ...coreRuntimeDeps, + ...collectRootDistMirroredRuntimeDeps({ + packageRoot, + runtimeDeps, + ownerPluginIds, + }), + ]); +} + +function packageNameFromSpecifier(specifier: string): string | null { + if ( + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("node:") || + specifier.startsWith("#") + ) { + return null; + } + const [first, second] = specifier.split("/"); + if (!first) { + return null; + } + return first.startsWith("@") && second ? `${first}/${second}` : first; +} + +function extractStaticRuntimeImportSpecifiers(source: string): string[] { + const specifiers = new Set(); + const patterns = [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s*["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + if (match[1]) { + specifiers.add(match[1]); + } + } + } + return [...specifiers]; +} + +function walkRuntimeDistJavaScriptFiles(params: { + rootDir: string; + skipTopLevelDirs?: ReadonlySet; +}): string[] { + if (!fs.existsSync(params.rootDir)) { + return []; + } + const files: string[] = []; + const queue = [params.rootDir]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + const isSkippedTopLevelDir = + path.resolve(current) === path.resolve(params.rootDir) && + params.skipTopLevelDirs?.has(entry.name); + if (entry.name !== "node_modules" && !isSkippedTopLevelDir) { + queue.push(fullPath); + } + continue; + } + if ( + entry.isFile() && + BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS.has(path.extname(entry.name)) + ) { + files.push(fullPath); + } + } + } + return files.toSorted((left, right) => left.localeCompare(right)); +} + +function isPluginOwnedDistImporter(params: { + relativePath: string; + source: string; + pluginIds: readonly string[]; +}): boolean { + return params.pluginIds.some( + (pluginId) => + params.relativePath.startsWith(`extensions/${pluginId}/`) || + params.source.includes(`//#region extensions/${pluginId}/`), + ); +} + +function collectBundledRuntimeDependencyOwners(packageRoot: string): Map< + string, + { + name: string; + version: string; + pluginIds: string[]; + } +> { + const extensionsDir = path.join(packageRoot, "dist", "extensions"); + if (!fs.existsSync(extensionsDir)) { + return new Map(); + } + const owners = new Map(); + for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const pluginId = entry.name; + const packageJson = readJsonObject(path.join(extensionsDir, pluginId, "package.json")); + if (!packageJson) { + continue; + } + for (const [name, rawVersion] of Object.entries(collectRuntimeDeps(packageJson))) { + const dep = parseInstallableRuntimeDep(name, rawVersion); + if (!dep) { + continue; + } + const existing = owners.get(dep.name); + if (existing) { + existing.pluginIds = [...new Set([...existing.pluginIds, pluginId])].toSorted( + (left, right) => left.localeCompare(right), + ); + continue; + } + owners.set(dep.name, { ...dep, pluginIds: [pluginId] }); + } + } + return owners; +} + +function collectRootDistMirroredRuntimeDeps(params: { + packageRoot: string; + runtimeDeps: Record; + ownerPluginIds?: ReadonlySet; +}): { name: string; version: string; pluginIds: string[] }[] { + const dependencyOwners = collectBundledRuntimeDependencyOwners(params.packageRoot); + if (dependencyOwners.size === 0) { + return []; + } + const mirrored = new Map(); + const distDir = path.join(params.packageRoot, "dist"); + for (const filePath of walkRuntimeDistJavaScriptFiles({ + rootDir: distDir, + skipTopLevelDirs: new Set(["extensions"]), + })) { + const source = fs.readFileSync(filePath, "utf8"); + const relativePath = path.relative(distDir, filePath).replaceAll(path.sep, "/"); + for (const specifier of extractStaticRuntimeImportSpecifiers(source)) { + const dependencyName = packageNameFromSpecifier(specifier); + if (!dependencyName) { + continue; + } + const owner = dependencyOwners.get(dependencyName); + if (!owner) { + continue; + } + if ( + params.ownerPluginIds && + !owner.pluginIds.some((pluginId) => params.ownerPluginIds?.has(pluginId)) + ) { + continue; + } + if (isPluginOwnedDistImporter({ relativePath, source, pluginIds: owner.pluginIds })) { + continue; + } + const dep = parseInstallableRuntimeDep(dependencyName, params.runtimeDeps[dependencyName]); + if (dep) { + mirrored.set(dep.name, { ...dep, pluginIds: owner.pluginIds }); + } + } + } + return [...mirrored.values()].toSorted((left, right) => { + const nameOrder = left.name.localeCompare(right.name); + return nameOrder === 0 ? left.version.localeCompare(right.version) : nameOrder; }); } @@ -1376,11 +1557,7 @@ export function scanBundledPluginRuntimeDeps(params: { }); const packageRuntimeDeps = pluginIds.length > 0 - ? collectMirroredPackageRuntimeDeps(params.packageRoot).map((dep) => ({ - name: dep.name, - version: dep.version, - pluginIds: [MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID], - })) + ? collectMirroredPackageRuntimeDeps(params.packageRoot, new Set(pluginIds)) : []; const allDeps = mergeRuntimeDepEntries([...deps, ...packageRuntimeDeps]); const packageInstallRootPlan = resolveBundledRuntimeDependencyPackageInstallRootPlan( @@ -1987,7 +2164,7 @@ export function ensureBundledPluginRuntimeDeps(params: { const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot); const packageRuntimeDeps = packageRoot && path.resolve(installRoot) !== path.resolve(params.pluginRoot) - ? collectMirroredPackageRuntimeDeps(packageRoot) + ? collectMirroredPackageRuntimeDeps(packageRoot, new Set([params.pluginId])) : []; const deps = mergeInstallableRuntimeDeps([...pluginDeps, ...packageRuntimeDeps]); if (deps.length === 0) {