From 646a268d2710bc54e478349ef5690f3c8803df4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 08:17:11 +0100 Subject: [PATCH] fix: stage mirrored logger runtime deps --- CHANGELOG.md | 1 + src/plugins/bundled-runtime-deps.test.ts | 136 +++++++++++++++++++++++ src/plugins/bundled-runtime-deps.ts | 109 +++++++++++++++--- 3 files changed, 229 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc41ffc0d4b..3706aa8b5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. - Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon. - macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. +- Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain. - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss. - TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant. - Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the machine user scope when `systemctl --user` reports no-medium bus failures. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, and @mssteuer. diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index ce23de0f8ae..857da9aa65b 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -859,6 +859,101 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { readFileSyncSpy.mock.calls.filter((call) => path.resolve(String(call[0])) === manifestPath), ).toHaveLength(1); }); + + it("reports missing mirrored core runtime deps for doctor repair", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.25", + dependencies: { tslog: "^4.10.2" }, + }), + ); + writeBundledPluginPackage({ + packageRoot, + pluginId: "discord", + deps: { "discord-runtime": "1.0.0" }, + enabledByDefault: true, + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot, + config: {}, + env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, + }); + + expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ + "discord-runtime@1.0.0", + "tslog@^4.10.2", + ]); + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ + "discord-runtime@1.0.0", + "tslog@^4.10.2", + ]); + }); + + it("reports missing mirrored core runtime deps for startup plugins without own deps", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.25", + dependencies: { tslog: "^4.10.2" }, + }), + ); + writeBundledPluginPackage({ + packageRoot, + pluginId: "slack", + deps: {}, + channels: ["slack"], + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot, + selectedPluginIds: ["slack"], + config: { + channels: { slack: { botToken: "xoxb-token" } }, + }, + env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, + }); + + expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["tslog@^4.10.2"]); + expect(result.deps[0]?.pluginIds).toEqual(["openclaw-core"]); + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["tslog@^4.10.2"]); + }); + + it("deduplicates mirrored core runtime deps already declared by a plugin", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.25", + dependencies: { tslog: "^4.10.2" }, + }), + ); + writeBundledPluginPackage({ + packageRoot, + pluginId: "logger-plugin", + deps: { tslog: "^4.10.2" }, + enabledByDefault: true, + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot, + config: {}, + env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, + }); + + expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["tslog@^4.10.2"]); + expect(result.deps[0]?.pluginIds).toEqual(["logger-plugin", "openclaw-core"]); + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["tslog@^4.10.2"]); + }); }); describe("ensureBundledPluginRuntimeDeps", () => { @@ -957,6 +1052,47 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(installRoot).not.toBe(pluginRoot); }); + it("installs mirrored core logger deps even when the plugin has no external deps", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.25", + dependencies: { tslog: "^4.10.2" }, + }), + ); + const pluginRoot = path.join(packageRoot, "dist", "extensions", "slack"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, "package.json"), JSON.stringify({ dependencies: {} })); + + const calls: BundledRuntimeDepsInstallParams[] = []; + const result = ensureBundledPluginRuntimeDeps({ + env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, + installDeps: (params) => { + calls.push(params); + }, + pluginId: "slack", + pluginRoot, + }); + + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { + env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, + }); + expect(result).toEqual({ + installedSpecs: ["tslog@^4.10.2"], + retainSpecs: ["tslog@^4.10.2"], + }); + expect(calls).toEqual([ + { + installRoot, + missingSpecs: ["tslog@^4.10.2"], + installSpecs: ["tslog@^4.10.2"], + }, + ]); + }); + it("uses external staging when a packaged plugin declares workspace:* deps", () => { // Regression guard for packaged/Docker bundled plugins whose `package.json` // still lists `"@openclaw/plugin-sdk": "workspace:*"` (and similar) alongside diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 252ba6ec2e5..bd39ee6b7c1 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -63,6 +63,8 @@ const BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS = 10 * 60_000; const BUNDLED_RUNTIME_DEPS_OWNERLESS_LOCK_STALE_MS = 30_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_PACKAGE_RUNTIME_DEP_PLUGIN_ID = "openclaw-core"; const registeredBundledRuntimeDepNodePaths = new Set(); @@ -456,6 +458,56 @@ function collectRuntimeDeps(packageJson: JsonObject): Record { }; } +function collectMirroredPackageRuntimeDeps(packageRoot: string | null): { + name: string; + version: string; +}[] { + if (!packageRoot) { + return []; + } + const packageJson = readJsonObject(path.join(packageRoot, "package.json")); + if (!packageJson) { + return []; + } + const runtimeDeps = collectRuntimeDeps(packageJson); + return MIRRORED_PACKAGE_RUNTIME_DEP_NAMES.flatMap((name) => { + const dep = parseInstallableRuntimeDep(name, runtimeDeps[name]); + return dep ? [dep] : []; + }); +} + +function mergeInstallableRuntimeDeps( + deps: readonly { name: string; version: string }[], +): { name: string; version: string }[] { + const bySpec = new Map(); + for (const dep of deps) { + bySpec.set(`${dep.name}@${dep.version}`, dep); + } + return [...bySpec.values()].toSorted((left, right) => { + const nameOrder = left.name.localeCompare(right.name); + return nameOrder === 0 ? left.version.localeCompare(right.version) : nameOrder; + }); +} + +function mergeRuntimeDepEntries(deps: readonly RuntimeDepEntry[]): RuntimeDepEntry[] { + const bySpec = new Map(); + for (const dep of deps) { + const spec = `${dep.name}@${dep.version}`; + const existing = bySpec.get(spec); + if (!existing) { + bySpec.set(spec, { ...dep, pluginIds: [...dep.pluginIds] }); + continue; + } + existing.pluginIds = [...new Set([...existing.pluginIds, ...dep.pluginIds])].toSorted( + (left, right) => left.localeCompare(right), + ); + } + return [...bySpec.values()].toSorted((left, right) => { + const nameOrder = left.name.localeCompare(right.name); + return nameOrder === 0 ? left.version.localeCompare(right.version) : nameOrder; + }); +} + function isSourceCheckoutRoot(packageRoot: string): boolean { return ( (fs.existsSync(path.join(packageRoot, ".git")) || @@ -1083,9 +1135,11 @@ function collectBundledPluginRuntimeDeps(params: { }): { deps: RuntimeDepEntry[]; conflicts: RuntimeDepConflict[]; + pluginIds: string[]; } { const versionMap = new Map>>(); const manifestCache: BundledPluginRuntimeDepsManifestCache = new Map(); + const includedPluginIds = new Set(); for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -1106,6 +1160,7 @@ function collectBundledPluginRuntimeDeps(params: { ) { continue; } + includedPluginIds.add(pluginId); const packageJson = readJsonObject(path.join(pluginDir, "package.json")); if (!packageJson) { continue; @@ -1155,6 +1210,7 @@ function collectBundledPluginRuntimeDeps(params: { return { deps: deps.toSorted((a, b) => a.name.localeCompare(b.name)), conflicts: conflicts.toSorted((a, b) => a.name.localeCompare(b.name)), + pluginIds: [...includedPluginIds].toSorted((a, b) => a.localeCompare(b)), }; } @@ -1189,29 +1245,42 @@ export function scanBundledPluginRuntimeDeps(params: { if (!fs.existsSync(extensionsDir)) { return { deps: [], missing: [], conflicts: [] }; } - const { deps, conflicts } = collectBundledPluginRuntimeDeps({ + const { deps, conflicts, pluginIds } = collectBundledPluginRuntimeDeps({ extensionsDir, config: params.config, pluginIds: normalizePluginIdSet(params.pluginIds), selectedPluginIds: normalizePluginIdSet(params.selectedPluginIds), includeConfiguredChannels: params.includeConfiguredChannels, }); + const packageRuntimeDeps = + pluginIds.length > 0 + ? collectMirroredPackageRuntimeDeps(params.packageRoot).map((dep) => ({ + name: dep.name, + version: dep.version, + pluginIds: [MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID], + })) + : []; + const allDeps = mergeRuntimeDepEntries([...deps, ...packageRuntimeDeps]); const packageInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(params.packageRoot, { env: params.env, }); const packageSearchRoots = [packageInstallRoot]; - const missing = deps.filter( - (dep) => - !hasDependencySentinel(packageSearchRoots, dep) && - dep.pluginIds.every((pluginId) => { - const pluginRoot = path.join(extensionsDir, pluginId); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: params.env, - }); - return !hasDependencySentinel([installRoot], dep); - }), - ); - return { deps, missing, conflicts }; + const missing = allDeps.filter((dep) => { + if (hasDependencySentinel(packageSearchRoots, dep)) { + return false; + } + if (dep.pluginIds.includes(MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID)) { + return true; + } + return dep.pluginIds.every((pluginId) => { + const pluginRoot = path.join(extensionsDir, pluginId); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { + env: params.env, + }); + return !hasDependencySentinel([installRoot], dep); + }); + }); + return { deps: allDeps, missing, conflicts }; } export function resolveBundledRuntimeDependencyPackageInstallRoot( @@ -1654,16 +1723,22 @@ export function ensureBundledPluginRuntimeDeps(params: { if (!packageJson) { return { installedSpecs: [], retainSpecs: [] }; } - const deps = Object.entries(collectRuntimeDeps(packageJson)) + const pluginDeps = Object.entries(collectRuntimeDeps(packageJson)) .map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion)) .filter((entry): entry is { name: string; version: string } => Boolean(entry)); - if (deps.length === 0) { - return { installedSpecs: [], retainSpecs: [] }; - } const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot, { env: params.env, }); + const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot); + const packageRuntimeDeps = + packageRoot && path.resolve(installRoot) !== path.resolve(params.pluginRoot) + ? collectMirroredPackageRuntimeDeps(packageRoot) + : []; + const deps = mergeInstallableRuntimeDeps([...pluginDeps, ...packageRuntimeDeps]); + if (deps.length === 0) { + return { installedSpecs: [], retainSpecs: [] }; + } return withBundledRuntimeDepsInstallRootLock(installRoot, () => { const persistRetainedManifest = shouldPersistRetainedRuntimeDepsManifest({ pluginRoot: params.pluginRoot,