mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix: stage packaged bundled runtime deps externally
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user