fix: stage mirrored logger runtime deps

This commit is contained in:
Peter Steinberger
2026-04-27 08:17:11 +01:00
parent 729147dcb5
commit 646a268d27
3 changed files with 229 additions and 17 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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<string>();
@@ -456,6 +458,56 @@ function collectRuntimeDeps(packageJson: JsonObject): Record<string, unknown> {
};
}
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<string, { name: string; version: string }>();
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<string, RuntimeDepEntry>();
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<string, Map<string, Set<string>>>();
const manifestCache: BundledPluginRuntimeDepsManifestCache = new Map();
const includedPluginIds = new Set<string>();
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,