diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 5ff96d89412..db71faededd 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -127,6 +127,29 @@ test -d "$package_root/dist/extensions/slack" test -d "$package_root/dist/extensions/feishu" test -d "$package_root/dist/extensions/memory-lancedb" +stage_root() { + printf "%s/.openclaw/plugin-runtime-deps" "$HOME" +} + +find_external_dep_package() { + local dep_path="$1" + find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true +} + +assert_package_dep_absent() { + local channel="$1" + local dep_path="$2" + for candidate in \ + "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$package_root/dist/extensions/node_modules/$dep_path/package.json" \ + "$package_root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + echo "packaged install should not mutate package tree for $channel: $candidate" >&2 + exit 1 + fi + done +} + if [ -d "$package_root/dist/extensions/$CHANNEL/node_modules" ]; then echo "$CHANNEL runtime deps should not be preinstalled in package" >&2 find "$package_root/dist/extensions/$CHANNEL/node_modules" -maxdepth 2 -type f | head -20 >&2 || true @@ -357,12 +380,10 @@ assert_installed_once() { if [ "$count" -eq 1 ]; then return 0 fi - if [ "$count" -eq 0 ] && [ -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then - return 0 - fi if [ "$count" -ne 1 ]; then - echo "expected exactly one runtime deps install log or installed sentinel for $channel, got $count log lines" >&2 + echo "expected exactly one runtime deps install log for $channel, got $count log lines" >&2 cat "$log_file" >&2 + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true exit 1 fi } @@ -380,18 +401,22 @@ assert_not_installed() { assert_dep_sentinel() { local channel="$1" local dep_path="$2" - if [ ! -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then - echo "missing dependency sentinel for $channel: $dep_path" >&2 - find "$package_root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true + local sentinel + sentinel="$(find_external_dep_package "$dep_path")" + if [ -z "$sentinel" ]; then + echo "missing external dependency sentinel for $channel: $dep_path" >&2 + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true exit 1 fi + assert_package_dep_absent "$channel" "$dep_path" } assert_no_dep_sentinel() { local channel="$1" local dep_path="$2" - if [ -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then - echo "dependency sentinel should be absent before activation for $channel: $dep_path" >&2 + assert_package_dep_absent "$channel" "$dep_path" + if [ -n "$(find_external_dep_package "$dep_path")" ]; then + echo "external dependency sentinel should be absent before activation for $channel: $dep_path" >&2 exit 1 fi } @@ -1063,6 +1088,15 @@ package_root() { printf "%s/openclaw" "$(npm root -g)" } +stage_root() { + printf "%s/.openclaw/plugin-runtime-deps" "$HOME" +} + +find_external_dep_package() { + local dep_path="$1" + find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true +} + package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" update_target="file:$package_tgz" candidate_version="$(node - <<'NODE' "$package_tgz" @@ -1182,12 +1216,15 @@ assert_dep_sentinel() { local channel="$1" local dep_path="$2" local root + local sentinel root="$(package_root)" - if [ ! -f "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then - echo "missing dependency sentinel for $channel: $dep_path" >&2 - find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true + sentinel="$(find_external_dep_package "$dep_path")" + if [ -z "$sentinel" ]; then + echo "missing external dependency sentinel for $channel: $dep_path" >&2 + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true exit 1 fi + assert_no_package_dep_available "$channel" "$dep_path" "$root" } assert_no_dep_sentinel() { @@ -1195,28 +1232,43 @@ assert_no_dep_sentinel() { local dep_path="$2" local root root="$(package_root)" - if [ -f "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then - echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2 + assert_no_package_dep_available "$channel" "$dep_path" "$root" + if [ -n "$(find_external_dep_package "$dep_path")" ]; then + echo "external dependency sentinel should be absent before repair for $channel: $dep_path" >&2 exit 1 fi } +assert_no_package_dep_available() { + local channel="$1" + local dep_path="$2" + local root="$3" + for candidate in \ + "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$root/dist/extensions/node_modules/$dep_path/package.json" \ + "$root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + echo "packaged install should not mutate package tree for $channel: $candidate" >&2 + exit 1 + fi + done +} + assert_dep_available() { local channel="$1" local dep_path="$2" local root + local sentinel root="$(package_root)" - for candidate in \ - "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ - "$root/dist/extensions/node_modules/$dep_path/package.json" \ - "$root/node_modules/$dep_path/package.json"; do - if [ -f "$candidate" ]; then - return 0 - fi - done + sentinel="$(find_external_dep_package "$dep_path")" + if [ -n "$sentinel" ]; then + assert_no_package_dep_available "$channel" "$dep_path" "$root" + return 0 + fi echo "missing dependency sentinel for $channel: $dep_path" >&2 find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true find "$root/node_modules" -maxdepth 3 -path "*/$dep_path/package.json" -type f -print >&2 || true + find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true exit 1 } @@ -1225,15 +1277,11 @@ assert_no_dep_available() { local dep_path="$2" local root root="$(package_root)" - for candidate in \ - "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ - "$root/dist/extensions/node_modules/$dep_path/package.json" \ - "$root/node_modules/$dep_path/package.json"; do - if [ -f "$candidate" ]; then - echo "dependency sentinel should be absent before repair for $channel: $dep_path ($candidate)" >&2 - exit 1 - fi - done + assert_no_package_dep_available "$channel" "$dep_path" "$root" + if [ -n "$(find_external_dep_package "$dep_path")" ]; then + echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2 + exit 1 + fi } remove_runtime_dep() { @@ -1244,6 +1292,7 @@ remove_runtime_dep() { rm -rf "$root/dist/extensions/$channel/node_modules" rm -rf "$root/dist/extensions/node_modules/$dep_path" rm -rf "$root/node_modules/$dep_path" + rm -rf "$(stage_root)" } assert_update_ok() { diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 53f008a3d22..c6039842ac7 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -5,15 +5,12 @@ import { describe, expect, it } from "vitest"; import { resolveBundledRuntimeDependencyPackageInstallRoot, scanBundledPluginRuntimeDeps, + type BundledRuntimeDepsInstallParams, } from "../plugins/bundled-runtime-deps.js"; import { maybeRepairBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; -type InstalledRuntimeDeps = Array<{ - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; -}>; +type InstalledRuntimeDeps = BundledRuntimeDepsInstallParams[]; function writeJson(filePath: string, value: unknown) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -49,6 +46,14 @@ function createInstalledRuntimeDeps(): InstalledRuntimeDeps { return []; } +function readRetainedRuntimeDepsManifest(installRoot: string): string[] { + const manifestPath = path.join(installRoot, ".openclaw-runtime-deps.json"); + const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { specs?: unknown }; + return Array.isArray(parsed.specs) + ? parsed.specs.filter((entry): entry is string => typeof entry === "string") + : []; +} + function createNonInteractivePrompter( options: { updateInProgress?: boolean } = {}, ): DoctorPrompter { @@ -122,7 +127,7 @@ describe("doctor bundled plugin runtime deps", () => { const result = scanBundledPluginRuntimeDeps({ packageRoot: root }); const missing = result.missing.map((dep) => `${dep.name}@${dep.version}`); - expect(missing).toEqual(["@scope/dep-two@2.0.0", "dep-opt@3.0.0"]); + expect(missing).toEqual(["@scope/dep-two@2.0.0", "dep-one@1.0.0", "dep-opt@3.0.0"]); expect(result.conflicts).toHaveLength(1); expect(result.conflicts[0]?.name).toBe("dep-conflict"); expect(result.conflicts[0]?.versions).toEqual(["1.0.0", "2.0.0"]); @@ -300,13 +305,16 @@ describe("doctor bundled plugin runtime deps", () => { }, }); + const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); expect(installed).toEqual([ { - installRoot: root, + installRoot, missingSpecs: ["grammy@1.37.0"], installSpecs: ["grammy@1.37.0"], }, ]); + expect(installRoot).not.toBe(root); + expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual(["grammy@1.37.0"]); }); it("repairs Feishu runtime deps from preserved source config", async () => { @@ -329,13 +337,15 @@ describe("doctor bundled plugin runtime deps", () => { }, }); + const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); expect(installed).toEqual([ { - installRoot: root, + installRoot, missingSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"], installSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"], }, ]); + expect(installRoot).not.toBe(root); }); it("repairs missing deps into an external stage dir when configured", async () => { @@ -369,16 +379,17 @@ describe("doctor bundled plugin runtime deps", () => { }, ]); expect(installRoot).toContain(stageDir); + expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual(["@slack/web-api@7.15.1"]); }); - it("retains configured bundled deps when repairing a subset", async () => { + it("retains already staged bundled deps when repairing a subset", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); writeBundledChannelPlugin(root, "slack", { "@slack/web-api": "7.15.1" }); - writeJson(path.join(root, "node_modules", "@slack", "web-api", "package.json"), { - name: "@slack/web-api", - version: "7.15.1", + const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); + writeJson(path.join(installRoot, ".openclaw-runtime-deps.json"), { + specs: ["@slack/web-api@7.15.1"], }); const installed = createInstalledRuntimeDeps(); @@ -401,10 +412,15 @@ describe("doctor bundled plugin runtime deps", () => { expect(installed).toEqual([ { - installRoot: root, + installRoot, missingSpecs: ["grammy@1.37.0"], - installSpecs: ["grammy@1.37.0"], + installSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"], }, ]); + expect(installRoot).not.toBe(root); + expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual([ + "@slack/web-api@7.15.1", + "grammy@1.37.0", + ]); }); }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index 31b9b8a0983..d82d626e86c 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -2,9 +2,10 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { - installBundledRuntimeDeps, + repairBundledRuntimeDepsInstallRoot, resolveBundledRuntimeDependencyPackageInstallRoot, scanBundledPluginRuntimeDeps, + type BundledRuntimeDepsInstallParams, } from "../plugins/bundled-runtime-deps.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; @@ -17,11 +18,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { env?: NodeJS.ProcessEnv; packageRoot?: string | null; includeConfiguredChannels?: boolean; - installDeps?: (params: { - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - }) => void; + installDeps?: (params: BundledRuntimeDepsInstallParams) => void; }): Promise { const packageRoot = params.packageRoot ?? @@ -89,16 +86,14 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env: params.env ?? process.env, }); - const install = - params.installDeps ?? - ((installParams) => - installBundledRuntimeDeps({ - installRoot: installParams.installRoot, - missingSpecs: installParams.installSpecs, - env: params.env ?? process.env, - })); - install({ installRoot, missingSpecs, installSpecs }); - note(`Installed bundled plugin deps: ${installSpecs.join(", ")}`, "Bundled plugins"); + const result = repairBundledRuntimeDepsInstallRoot({ + installRoot, + missingSpecs, + installSpecs, + env: params.env ?? process.env, + installDeps: params.installDeps, + }); + note(`Installed bundled plugin deps: ${result.installSpecs.join(", ")}`, "Bundled plugins"); } catch (error) { params.runtime.error(`Failed to install bundled plugin runtime deps: ${String(error)}`); } diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 221b17fbffe..745461d784b 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -428,17 +428,18 @@ describe("ensureBundledPluginRuntimeDeps", () => { }); expect(result).toEqual({ - installedSpecs: ["missing@2.0.0"], + installedSpecs: ["already-present@1.0.0", "missing@2.0.0"], retainSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"], }); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); expect(calls).toEqual([ { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), - missingSpecs: ["missing@2.0.0"], + installRoot, + missingSpecs: ["already-present@1.0.0", "missing@2.0.0"], installSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"], }, ]); + expect(installRoot).not.toBe(pluginRoot); }); it("skips workspace-only runtime deps before npm install", () => { @@ -471,17 +472,18 @@ describe("ensureBundledPluginRuntimeDeps", () => { installedSpecs: ["external-runtime@^1.2.3"], retainSpecs: ["external-runtime@^1.2.3"], }); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); expect(calls).toEqual([ { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), + installRoot, missingSpecs: ["external-runtime@^1.2.3"], installSpecs: ["external-runtime@^1.2.3"], }, ]); + expect(installRoot).not.toBe(pluginRoot); }); - it("stages plugin-root install when the plugin's own package.json declares workspace:* deps", () => { + 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 // concrete runtime deps. Without a distinct execution root, `npm install` @@ -515,19 +517,15 @@ describe("ensureBundledPluginRuntimeDeps", () => { installedSpecs: ["@anthropic-ai/sdk@^0.50.0"], retainSpecs: ["@anthropic-ai/sdk@^0.50.0"], }); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); expect(calls).toEqual([ { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), + installRoot, missingSpecs: ["@anthropic-ai/sdk@^0.50.0"], installSpecs: ["@anthropic-ai/sdk@^0.50.0"], }, ]); - // The stage dir must be distinct from the plugin root so npm does not read - // the plugin's cwd manifest during install. - const installExecutionRoot = calls[0]?.installExecutionRoot; - expect(installExecutionRoot).toBeDefined(); - expect(path.resolve(installExecutionRoot ?? "")).not.toEqual(path.resolve(pluginRoot)); + expect(installRoot).not.toBe(pluginRoot); }); it("installs runtime deps into an external stage dir and exposes loader aliases", () => { @@ -657,6 +655,58 @@ describe("ensureBundledPluginRuntimeDeps", () => { ]); }); + it("retains existing staged deps without a retained manifest before shared installs", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.22" }), + ); + const alphaRoot = path.join(packageRoot, "dist", "extensions", "alpha"); + const betaRoot = path.join(packageRoot, "dist", "extensions", "beta"); + fs.mkdirSync(alphaRoot, { recursive: true }); + fs.mkdirSync(betaRoot, { recursive: true }); + fs.writeFileSync( + path.join(alphaRoot, "package.json"), + JSON.stringify({ dependencies: { "alpha-runtime": "1.0.0" } }), + ); + fs.writeFileSync( + path.join(betaRoot, "package.json"), + JSON.stringify({ dependencies: { "beta-runtime": "2.0.0" } }), + ); + + const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; + const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); + fs.mkdirSync(path.join(installRoot, "node_modules", "alpha-runtime"), { recursive: true }); + fs.writeFileSync( + path.join(installRoot, "node_modules", "alpha-runtime", "package.json"), + JSON.stringify({ name: "alpha-runtime", version: "1.0.0" }), + ); + expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false); + + const calls: BundledRuntimeDepsInstallParams[] = []; + const result = ensureBundledPluginRuntimeDeps({ + env, + installDeps: (params) => { + calls.push(params); + }, + pluginId: "beta", + pluginRoot: betaRoot, + }); + + expect(result).toEqual({ + installedSpecs: ["beta-runtime@2.0.0"], + retainSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], + }); + expect(calls).toEqual([ + { + installRoot, + missingSpecs: ["beta-runtime@2.0.0"], + installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], + }, + ]); + }); + it("does not expire active runtime-deps install locks by age alone", () => { expect( bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( @@ -679,7 +729,8 @@ describe("ensureBundledPluginRuntimeDeps", () => { }, }), ); - const lockDir = path.join(pluginRoot, ".openclaw-runtime-deps.lock"); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); + const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock"); fs.mkdirSync(lockDir, { recursive: true }); fs.writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 0, createdAtMs: 0 })); @@ -1008,7 +1059,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(installRoot).not.toBe(pluginRoot); }); - it("skips install when staged plugin-local runtime deps are present", () => { + it("repairs external staged deps even when packaged plugin-local deps are present", () => { const packageRoot = makeTempDir(); const extensionsRoot = path.join(packageRoot, "dist", "extensions"); const pluginRoot = path.join(extensionsRoot, "discord"); @@ -1028,16 +1079,36 @@ describe("ensureBundledPluginRuntimeDeps", () => { JSON.stringify({ name: "@buape/carbon", version: "0.16.0" }), ); + const calls: BundledRuntimeDepsInstallParams[] = []; const result = ensureBundledPluginRuntimeDeps({ env: {}, - installDeps: () => { - throw new Error("staged plugin-local deps should not reinstall"); + installDeps: (params) => { + calls.push(params); + fs.mkdirSync(path.join(params.installRoot, "node_modules", "@buape", "carbon"), { + recursive: true, + }); + fs.writeFileSync( + path.join(params.installRoot, "node_modules", "@buape", "carbon", "package.json"), + JSON.stringify({ name: "@buape/carbon", version: "0.16.0" }), + ); }, pluginId: "discord", pluginRoot, }); - expect(result).toEqual({ installedSpecs: [], retainSpecs: [] }); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); + expect(result).toEqual({ + installedSpecs: ["@buape/carbon@0.16.0"], + retainSpecs: ["@buape/carbon@0.16.0"], + }); + expect(calls).toEqual([ + { + installRoot, + missingSpecs: ["@buape/carbon@0.16.0"], + installSpecs: ["@buape/carbon@0.16.0"], + }, + ]); + expect(installRoot).not.toBe(pluginRoot); }); it("does not trust runtime deps that only resolve from the package root", () => { @@ -1074,14 +1145,15 @@ describe("ensureBundledPluginRuntimeDeps", () => { installedSpecs: ["@mariozechner/pi-ai@0.68.1"], retainSpecs: ["@mariozechner/pi-ai@0.68.1"], }); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); expect(calls).toEqual([ { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), + installRoot, missingSpecs: ["@mariozechner/pi-ai@0.68.1"], installSpecs: ["@mariozechner/pi-ai@0.68.1"], }, ]); + expect(installRoot).not.toBe(pluginRoot); }); it("installs deps that are only present in the package root", () => { @@ -1117,14 +1189,15 @@ describe("ensureBundledPluginRuntimeDeps", () => { installedSpecs: ["ws@^8.20.0", "zod@^4.3.6"], retainSpecs: ["ws@^8.20.0", "zod@^4.3.6"], }); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); expect(calls).toEqual([ { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), + installRoot, missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"], installSpecs: ["ws@^8.20.0", "zod@^4.3.6"], }, ]); + expect(installRoot).not.toBe(pluginRoot); }); it("does not treat sibling extension runtime deps as satisfying a plugin", () => { @@ -1162,14 +1235,15 @@ describe("ensureBundledPluginRuntimeDeps", () => { installedSpecs: ["zod@^4.3.6"], retainSpecs: ["zod@^4.3.6"], }); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); expect(calls).toEqual([ { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), + installRoot, missingSpecs: ["zod@^4.3.6"], installSpecs: ["zod@^4.3.6"], }, ]); + expect(installRoot).not.toBe(pluginRoot); }); it("rejects unsupported remote runtime dependency specs", () => { diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 817c1db18b2..72f367a4dc6 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -324,6 +324,11 @@ function resolveBundledPluginPackageRoot(pluginRoot: string): string | null { return path.dirname(buildDir); } +function isPackagedBundledPluginRoot(pluginRoot: string): boolean { + const packageRoot = resolveBundledPluginPackageRoot(pluginRoot); + return Boolean(packageRoot && !isSourceCheckoutRoot(packageRoot)); +} + function createRuntimeDepsCacheKey(pluginId: string, specs: readonly string[]): string { return createHash("sha256") .update(pluginId) @@ -371,6 +376,25 @@ function removeRetainedRuntimeDepsManifest(installRoot: string): void { fs.rmSync(path.join(installRoot, RETAINED_RUNTIME_DEPS_MANIFEST), { force: true }); } +function collectAlreadyStagedBundledRuntimeDepSpecs(params: { + pluginRoot: string; + installRoot: string; +}): string[] { + const packageRoot = resolveBundledPluginPackageRoot(params.pluginRoot); + if (!packageRoot) { + return []; + } + const extensionsDir = path.join(packageRoot, "dist", "extensions"); + if (!fs.existsSync(extensionsDir)) { + return []; + } + const { deps } = collectBundledPluginRuntimeDeps({ extensionsDir }); + return deps + .filter((dep) => hasDependencySentinel([params.installRoot], dep)) + .map((dep) => `${dep.name}@${dep.version}`) + .toSorted((left, right) => left.localeCompare(right)); +} + function shouldPersistRetainedRuntimeDepsManifest(params: { pluginRoot: string; installRoot: string; @@ -861,22 +885,19 @@ export function resolveBundledRuntimeDependencyPackageInstallRoot( options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {}, ): string { const env = options.env ?? process.env; + const externalRoot = resolveExternalBundledRuntimeDepsInstallRoot({ + pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"), + env, + }); if ( options.forceExternal || env.OPENCLAW_PLUGIN_STAGE_DIR?.trim() || - env.STATE_DIRECTORY?.trim() + env.STATE_DIRECTORY?.trim() || + !isSourceCheckoutRoot(packageRoot) ) { - return resolveExternalBundledRuntimeDepsInstallRoot({ - pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"), - env, - }); + return externalRoot; } - return isWritableDirectory(packageRoot) - ? packageRoot - : resolveExternalBundledRuntimeDepsInstallRoot({ - pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"), - env, - }); + return isWritableDirectory(packageRoot) ? packageRoot : externalRoot; } export function resolveBundledRuntimeDependencyInstallRoot( @@ -884,16 +905,16 @@ export function resolveBundledRuntimeDependencyInstallRoot( options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {}, ): string { const env = options.env ?? process.env; + const externalRoot = resolveExternalBundledRuntimeDepsInstallRoot({ pluginRoot, env }); if ( options.forceExternal || env.OPENCLAW_PLUGIN_STAGE_DIR?.trim() || - env.STATE_DIRECTORY?.trim() + env.STATE_DIRECTORY?.trim() || + isPackagedBundledPluginRoot(pluginRoot) ) { - return resolveExternalBundledRuntimeDepsInstallRoot({ pluginRoot, env }); + return externalRoot; } - return isWritableDirectory(pluginRoot) - ? pluginRoot - : resolveExternalBundledRuntimeDepsInstallRoot({ pluginRoot, env }); + return isWritableDirectory(pluginRoot) ? pluginRoot : externalRoot; } export function resolveBundledRuntimeDependencyInstallRootInfo( @@ -1000,6 +1021,36 @@ export function installBundledRuntimeDeps(params: { } } +export function repairBundledRuntimeDepsInstallRoot(params: { + installRoot: string; + missingSpecs: string[]; + installSpecs: string[]; + env: NodeJS.ProcessEnv; + installDeps?: (params: BundledRuntimeDepsInstallParams) => void; +}): { installSpecs: string[] } { + return withBundledRuntimeDepsInstallRootLock(params.installRoot, () => { + const retainedManifestSpecs = readRetainedRuntimeDepsManifest(params.installRoot); + const installSpecs = [...new Set([...retainedManifestSpecs, ...params.installSpecs])].toSorted( + (left, right) => left.localeCompare(right), + ); + const install = + params.installDeps ?? + ((installParams) => + installBundledRuntimeDeps({ + installRoot: installParams.installRoot, + missingSpecs: installParams.installSpecs ?? installParams.missingSpecs, + env: params.env, + })); + install({ + installRoot: params.installRoot, + missingSpecs: params.missingSpecs, + installSpecs, + }); + writeRetainedRuntimeDepsManifest(params.installRoot, installSpecs); + return { installSpecs }; + }); +} + export function ensureBundledPluginRuntimeDeps(params: { pluginId: string; pluginRoot: string; @@ -1043,19 +1094,33 @@ export function ensureBundledPluginRuntimeDeps(params: { const dependencySpecs = deps .map((dep) => `${dep.name}@${dep.version}`) .toSorted((left, right) => left.localeCompare(right)); + const retainedManifestSpecs = persistRetainedManifest + ? readRetainedRuntimeDepsManifest(installRoot) + : []; + const alreadyStagedSpecs = persistRetainedManifest + ? collectAlreadyStagedBundledRuntimeDepSpecs({ + pluginRoot: params.pluginRoot, + installRoot, + }) + : []; + const installSpecs = [ + ...new Set([ + ...(params.retainSpecs ?? []), + ...retainedManifestSpecs, + ...alreadyStagedSpecs, + ...dependencySpecs, + ]), + ].toSorted((left, right) => left.localeCompare(right)); const missingSpecs = deps .filter((dep) => !hasDependencySentinel([installRoot], dep)) .map((dep) => `${dep.name}@${dep.version}`) .toSorted((left, right) => left.localeCompare(right)); if (missingSpecs.length === 0) { + if (persistRetainedManifest && installSpecs.length > 0) { + writeRetainedRuntimeDepsManifest(installRoot, installSpecs); + } return { installedSpecs: [], retainSpecs: [] }; } - const retainedManifestSpecs = persistRetainedManifest - ? readRetainedRuntimeDepsManifest(installRoot) - : []; - const installSpecs = [ - ...new Set([...(params.retainSpecs ?? []), ...retainedManifestSpecs, ...dependencySpecs]), - ].toSorted((left, right) => left.localeCompare(right)); const cacheDir = resolveSourceCheckoutRuntimeDepsCacheDir({ pluginId: params.pluginId, pluginRoot: params.pluginRoot,