fix: stage packaged bundled runtime deps externally

This commit is contained in:
Peter Steinberger
2026-04-25 01:58:29 +01:00
parent 2d2402cee8
commit d42b0e043c
5 changed files with 307 additions and 108 deletions

View File

@@ -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() {

View File

@@ -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",
]);
});
});

View File

@@ -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<void> {
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)}`);
}

View File

@@ -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", () => {

View File

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