fix(plugins): keep mirrored runtime deps on staged root

This commit is contained in:
Peter Steinberger
2026-04-25 21:35:37 +01:00
parent 34fb96622e
commit c3a3ceefbe
4 changed files with 89 additions and 0 deletions

View File

@@ -62,6 +62,9 @@ Docs: https://docs.openclaw.ai
- Providers/Google: transcode Gemini TTS PCM to Opus for voice-note targets so
WhatsApp and other native voice-note replies can play as voice messages.
- Plugins/runtime deps: reuse existing external bundled-plugin stage roots when
mirrored plugin roots are inspected again, avoiding second-generation
`openclaw-unknown-*` stages and repeated first-turn restaging. Fixes #71599.
- iOS/macOS Talk Mode: allow `talk.speechLocale` to set the speech
recognition locale for non-English voice conversations. Fixes #44688.
- Plugins/providers: honor explicit plugin candidate lists instead of reading a

View File

@@ -1107,6 +1107,14 @@ find_external_dep_package() {
find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true
}
assert_no_unknown_stage_roots() {
if find "$(stage_root)" -maxdepth 1 -type d -name 'openclaw-unknown-*' -print -quit 2>/dev/null | grep -q .; then
echo "runtime deps created second-generation unknown stage roots" >&2
find "$(stage_root)" -maxdepth 1 -type d -name 'openclaw-*' -print | sort >&2 || true
exit 1
fi
}
package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}"
update_target="file:$package_tgz"
candidate_version="$(node - <<'NODE' "$package_tgz"
@@ -1391,6 +1399,7 @@ if should_run_update_target telegram; then
cat /tmp/openclaw-update-telegram.json
assert_update_ok /tmp/openclaw-update-telegram.json "$candidate_version"
assert_dep_available telegram grammy
assert_no_unknown_stage_roots
echo "Mutating installed package: remove Telegram deps, then update-mode doctor repairs them..."
remove_runtime_dep telegram grammy
@@ -1401,6 +1410,7 @@ if should_run_update_target telegram; then
exit 1
fi
assert_dep_available telegram grammy
assert_no_unknown_stage_roots
fi
if should_run_update_target discord; then

View File

@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -726,6 +727,56 @@ describe("ensureBundledPluginRuntimeDeps", () => {
]);
});
it("does not derive a second-generation stage root from external runtime mirrors", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.25" }),
);
const pluginRoot = path.join(packageRoot, "dist", "extensions", "telegram");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({ dependencies: { grammy: "^1.42.0" } }),
);
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
const mirroredPluginRoot = path.join(installRoot, "dist", "extensions", "telegram");
fs.mkdirSync(mirroredPluginRoot, { recursive: true });
fs.writeFileSync(
path.join(mirroredPluginRoot, "package.json"),
JSON.stringify({ dependencies: { grammy: "^1.42.0" } }),
);
fs.mkdirSync(path.join(installRoot, "node_modules", "grammy"), { recursive: true });
fs.writeFileSync(
path.join(installRoot, "node_modules", "grammy", "package.json"),
JSON.stringify({ name: "grammy", version: "1.42.0" }),
);
const nestedUnknownRoot = path.join(
stageDir,
`openclaw-unknown-${createHash("sha256").update(path.resolve(installRoot)).digest("hex").slice(0, 12)}`,
);
expect(resolveBundledRuntimeDependencyInstallRoot(mirroredPluginRoot, { env })).toBe(
installRoot,
);
expect(resolveBundledRuntimeDependencyInstallRoot(mirroredPluginRoot, { env })).not.toBe(
nestedUnknownRoot,
);
expect(
ensureBundledPluginRuntimeDeps({
env,
installDeps: () => {
throw new Error("mirrored staged deps should not reinstall into a nested stage root");
},
pluginId: "telegram",
pluginRoot: mirroredPluginRoot,
}),
).toEqual({ installedSpecs: [], retainSpecs: [] });
});
it("retains existing staged deps without a retained manifest before shared installs", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();

View File

@@ -607,11 +607,36 @@ function resolveExternalBundledRuntimeDepsInstallRoot(params: {
env: NodeJS.ProcessEnv;
}): string {
const packageRoot = resolveBundledPluginPackageRoot(params.pluginRoot) ?? params.pluginRoot;
const existingExternalRoot = resolveExistingExternalBundledRuntimeDepsRoot({
packageRoot,
env: params.env,
});
if (existingExternalRoot) {
return existingExternalRoot;
}
const version = sanitizePathSegment(readPackageVersion(packageRoot));
const packageKey = `openclaw-${version}-${createPathHash(packageRoot)}`;
return path.join(resolveBundledRuntimeDepsExternalBaseDir(params.env), packageKey);
}
function resolveExistingExternalBundledRuntimeDepsRoot(params: {
packageRoot: string;
env: NodeJS.ProcessEnv;
}): string | null {
const externalBaseDir = path.resolve(resolveBundledRuntimeDepsExternalBaseDir(params.env));
const packageRoot = path.resolve(params.packageRoot);
const relative = path.relative(externalBaseDir, packageRoot);
if (
relative === "" ||
relative.startsWith("..") ||
path.isAbsolute(relative) ||
relative.includes(path.sep)
) {
return null;
}
return path.basename(packageRoot).startsWith("openclaw-") ? packageRoot : null;
}
function resolveSourceCheckoutRuntimeDepsCacheDir(params: {
pluginId: string;
pluginRoot: string;