diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ca3799d4d3..c57d458103e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 905927505da..623fbfb50cc 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -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 diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 98dbe5e0553..910cc598101 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 7c430fc9c42..d170672d10e 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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;