fix(plugins): relink host runtime dependencies (#117408)

* fix(plugins): relink host runtime dependencies

* fix(plugins): remove stale peer helper

* chore(release): leave changelog ownership unchanged

* fix(plugins): clarify host link recovery
This commit is contained in:
Vincent Koc
2026-08-01 21:27:06 +08:00
committed by GitHub
parent bba7b939ce
commit 355295bfef
11 changed files with 200 additions and 37 deletions
+9 -10
View File
@@ -44,22 +44,21 @@ export async function readInstalledPackageVersion(dir: string): Promise<string |
return typeof manifest?.version === "string" ? manifest.version : undefined;
}
/** Read string-valued peer dependencies from an installed package. */
export function readInstalledPackagePeerDependencies(dir: string): Record<string, string> {
/** Read the installed package declaration that requires the OpenClaw host link. */
export function readInstalledPackageOpenClawLinkDependencies(dir: string): Record<string, string> {
const manifest = readInstalledPackageManifest(dir);
const peerDependencies = isRecord(manifest?.peerDependencies) ? manifest.peerDependencies : {};
return Object.fromEntries(
Object.entries(peerDependencies).filter((entry): entry is [string, string] => {
const [, value] = entry;
return typeof value === "string";
}),
);
const dependencies = isRecord(manifest?.dependencies) ? manifest.dependencies : {};
const peerSpec = peerDependencies.openclaw;
const dependencySpec = dependencies.openclaw;
const spec = typeof peerSpec === "string" ? peerSpec : dependencySpec;
return typeof spec === "string" ? { openclaw: spec } : {};
}
/** Return true when an installed package needs an openclaw peer link repair. */
export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean {
const peerDependencies = readInstalledPackagePeerDependencies(dir);
if (!Object.hasOwn(peerDependencies, "openclaw")) {
const linkDependencies = readInstalledPackageOpenClawLinkDependencies(dir);
if (!Object.hasOwn(linkDependencies, "openclaw")) {
return false;
}
@@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const runInstallPolicyMock = vi.fn();
const findBlockedManifestDependenciesMock = vi.fn();
@@ -42,8 +45,15 @@ const {
preflightPluginNpmInstallPolicyRuntime,
scanBundleInstallSourceRuntime,
scanFileInstallSourceRuntime,
scanInstalledPackageDependencyTreeRuntime,
} = await import("./install-security-scan.runtime.js");
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
});
function expectOnlyOperatorPolicyRan() {
expect(runInstallPolicyMock).toHaveBeenCalledTimes(1);
expect(findBlockedManifestDependenciesMock).not.toHaveBeenCalled();
@@ -176,6 +186,67 @@ describe("install security scan official bypass", () => {
});
});
describe("installed dependency tree scan", () => {
it("accepts a managed host link declared as a runtime dependency", async () => {
findBlockedManifestDependenciesMock.mockReturnValue([]);
const npmRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-scan-"));
tempDirs.push(npmRoot);
const packageDir = path.join(npmRoot, "node_modules", "runtime-plugin");
const hostLink = path.join(packageDir, "node_modules", "openclaw");
await fs.mkdir(path.dirname(hostLink), { recursive: true });
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify({
name: "runtime-plugin",
dependencies: { openclaw: "2026.7.1" },
}),
"utf8",
);
await fs.symlink(process.cwd(), hostLink, "junction");
const result = await scanInstalledPackageDependencyTreeRuntime({
allowManagedNpmRootPackagePeerSymlinks: true,
dependencyScanRootDir: npmRoot,
logger: {},
packageDir,
pluginId: "runtime-plugin",
});
expect(result).toBeUndefined();
expect(runInstallPolicyMock).toHaveBeenCalledTimes(1);
});
it("rejects an openclaw dependency symlink that does not target the trusted host", async () => {
findBlockedManifestDependenciesMock.mockReturnValue([]);
const npmRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-scan-"));
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-outside-"));
tempDirs.push(npmRoot, outsideRoot);
const packageDir = path.join(npmRoot, "node_modules", "runtime-plugin");
const hostLink = path.join(packageDir, "node_modules", "openclaw");
await fs.mkdir(path.dirname(hostLink), { recursive: true });
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify({
name: "runtime-plugin",
dependencies: { openclaw: "2026.7.1" },
}),
"utf8",
);
await fs.writeFile(path.join(outsideRoot, "package.json"), '{"name":"openclaw"}', "utf8");
await fs.symlink(outsideRoot, hostLink, "junction");
await expect(
scanInstalledPackageDependencyTreeRuntime({
allowManagedNpmRootPackagePeerSymlinks: true,
dependencyScanRootDir: npmRoot,
logger: {},
packageDir,
pluginId: "runtime-plugin",
}),
).rejects.toThrow("installed dependency scan found package outside install root");
});
});
describe("legacy file install scan compatibility", () => {
it("preserves policy and hook metadata for published lazy install chunks", async () => {
const warnings: string[] = [];
@@ -362,9 +362,11 @@ function collectManifestRuntimeDependencyNames(manifest: PackageManifest): strin
}
async function resolveInstalledPackageScanRoot(params: {
allowManagedNpmRootPackagePeerSymlinks?: boolean;
boundaryRealPath: string;
dependencyName: string;
packageDir: string;
trustedHostOpenClawRootRealPath: string | null;
}): Promise<InstalledPackageScanRoot | undefined> {
const packageDir = path.join(params.packageDir, "node_modules", params.dependencyName);
let stats: Awaited<ReturnType<typeof fs.stat>>;
@@ -382,6 +384,16 @@ async function resolveInstalledPackageScanRoot(params: {
const realPath = await fs.realpath(packageDir).catch(() => path.resolve(packageDir));
if (!isSamePathOrInside(params.boundaryRealPath, realPath)) {
if (
params.allowManagedNpmRootPackagePeerSymlinks === true &&
params.dependencyName === "openclaw" &&
isTrustedHostOpenClawPath({
resolvedTargetPath: realPath,
trustedHostOpenClawRootRealPath: params.trustedHostOpenClawRootRealPath,
})
) {
return undefined;
}
throw new Error(
`installed dependency scan found package outside install root at ${packageDir}`,
);
@@ -391,12 +403,14 @@ async function resolveInstalledPackageScanRoot(params: {
async function collectInstalledPackageScanRoots(params: {
additionalPackageDirs?: string[];
allowManagedNpmRootPackagePeerSymlinks?: boolean;
dependencyScanRootDir?: string;
packageDir: string;
}): Promise<string[]> {
const limits = resolvePackageManifestTraversalLimits();
const boundaryDir = params.dependencyScanRootDir ?? params.packageDir;
const boundaryRealPath = await fs.realpath(boundaryDir).catch(() => path.resolve(boundaryDir));
const trustedHostOpenClawRootRealPath = await resolveTrustedHostOpenClawRootRealPath();
const packageRealPath = await fs
.realpath(params.packageDir)
.catch(() => path.resolve(params.packageDir));
@@ -444,17 +458,21 @@ async function collectInstalledPackageScanRoots(params: {
}
for (const dependencyName of collectManifestRuntimeDependencyNames(manifest)) {
const nestedCandidate = await resolveInstalledPackageScanRoot({
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
boundaryRealPath,
dependencyName,
packageDir: current.packageDir,
trustedHostOpenClawRootRealPath,
});
const candidate =
nestedCandidate ??
(params.dependencyScanRootDir
? await resolveInstalledPackageScanRoot({
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
boundaryRealPath,
dependencyName,
packageDir: params.dependencyScanRootDir,
trustedHostOpenClawRootRealPath,
})
: undefined);
if (candidate && !visitedRealPaths.has(candidate.realPath)) {
@@ -1114,6 +1132,7 @@ export async function scanInstalledPackageDependencyTreeRuntime(params: {
? { additionalPackageDirs: params.additionalPackageDirs }
: {}),
dependencyScanRootDir: params.dependencyScanRootDir,
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
packageDir: params.packageDir,
});
const manifestScanRoots = await collectNonOverlappingPackageScanRoots(scanRoots);
+1 -1
View File
@@ -34,7 +34,7 @@ export type PluginInstallRuntime = Awaited<ReturnType<typeof loadPluginInstallRu
export const defaultLogger: PluginInstallLogger = {};
export function formatUnresolvedOpenClawPeerLinkError(packageName: string): string {
return `Installed plugin ${packageName} declares openclaw as a peer dependency, but OpenClaw could not create a plugin-local node_modules/openclaw link. Run from a packaged OpenClaw install or reinstall OpenClaw, then retry.`;
return `Installed plugin ${packageName} declares an openclaw dependency, but OpenClaw could not create a plugin-local node_modules/openclaw link. Run from a packaged OpenClaw install or reinstall OpenClaw, then retry.`;
}
const MISSING_EXTENSIONS_ERROR =
+27
View File
@@ -52,6 +52,33 @@ describe("plugin peer links", () => {
expect(messages.join("\n")).toContain('Linked peerDependency "openclaw"');
});
it("relinks openclaw runtime dependencies in the managed npm root", async () => {
const npmRoot = makeTempDir();
const packageDir = path.join(npmRoot, "node_modules", "runtime-plugin");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({
name: "runtime-plugin",
version: "1.0.0",
dependencies: {
openclaw: "2026.7.1",
},
}),
"utf8",
);
const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({
npmRoot,
logger: {},
});
const linkPath = path.join(packageDir, "node_modules", "openclaw");
expect(result).toEqual({ checked: 1, attempted: 1, repaired: 1, skipped: 0 });
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(linkPath)).toBe(fs.realpathSync(process.cwd()));
});
it("reports one unreadable package and continues repairing its sibling", async () => {
const npmRoot = makeTempDir();
const unreadableDir = path.join(npmRoot, "node_modules", "bad-plugin");
+16 -11
View File
@@ -43,11 +43,16 @@ function readStringRecord(value: unknown): Record<string, string> {
return record;
}
async function readPackagePeerDependencies(packageDir: string): Promise<Record<string, string>> {
async function readPackageOpenClawLinkDependencies(
packageDir: string,
): Promise<Record<string, string>> {
try {
const raw = await fs.readFile(path.join(packageDir, "package.json"), "utf8");
const parsed = JSON.parse(raw) as { peerDependencies?: unknown };
return readStringRecord(parsed.peerDependencies);
const parsed = JSON.parse(raw) as { dependencies?: unknown; peerDependencies?: unknown };
const peerDependencies = readStringRecord(parsed.peerDependencies);
const dependencies = readStringRecord(parsed.dependencies);
const openclaw = peerDependencies.openclaw ?? dependencies.openclaw;
return openclaw ? { openclaw } : {};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {};
@@ -293,7 +298,7 @@ async function readPackageName(packageDir: string): Promise<string | undefined>
}
/**
* Symlink the host openclaw package for plugins that declare it as a peer.
* Symlink the host openclaw package for plugins that declare it as a dependency.
* Plugin package managers still own third-party dependencies; this only wires
* the host SDK package into the plugin-local Node graph.
*/
@@ -347,9 +352,9 @@ export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
let repaired = 0;
let skipped = 0;
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
let peerDependencies: Record<string, string>;
let openClawLinkDependencies: Record<string, string>;
try {
peerDependencies = await readPackagePeerDependencies(packageDir);
openClawLinkDependencies = await readPackageOpenClawLinkDependencies(packageDir);
} catch (error) {
if (!params.onPackageReadError) {
throw error;
@@ -358,13 +363,13 @@ export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
skipped += 1;
continue;
}
if (!Object.hasOwn(peerDependencies, "openclaw")) {
if (!Object.hasOwn(openClawLinkDependencies, "openclaw")) {
continue;
}
checked += 1;
const result = await linkOpenClawPeerDependencies({
installedDir: packageDir,
peerDependencies,
peerDependencies: openClawLinkDependencies,
logger: params.logger,
});
attempted += 1;
@@ -390,9 +395,9 @@ export async function auditOpenClawPeerDependenciesInManagedNpmRoot(params: {
let checked = 0;
const issues: OpenClawPeerLinkAuditIssue[] = [];
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
let peerDependencies: Record<string, string>;
let openClawLinkDependencies: Record<string, string>;
try {
peerDependencies = await readPackagePeerDependencies(packageDir);
openClawLinkDependencies = await readPackageOpenClawLinkDependencies(packageDir);
} catch (error) {
if (!params.onPackageReadError) {
throw error;
@@ -400,7 +405,7 @@ export async function auditOpenClawPeerDependenciesInManagedNpmRoot(params: {
params.onPackageReadError(error, packageDir);
continue;
}
if (!Object.hasOwn(peerDependencies, "openclaw")) {
if (!Object.hasOwn(openClawLinkDependencies, "openclaw")) {
continue;
}
checked += 1;
+8 -5
View File
@@ -123,11 +123,14 @@ function buildPluginRecordFromInstalledIndex(
hookCount: 0,
configSchema: Boolean(manifest?.configSchema),
contracts: manifest?.contracts,
dependencyStatus: buildPluginDependencyStatus({
rootDir: plugin.rootDir,
dependencies: manifest?.packageDependencies,
optionalDependencies: manifest?.packageOptionalDependencies,
}),
dependencyStatus:
plugin.origin === "bundled"
? undefined
: buildPluginDependencyStatus({
rootDir: plugin.rootDir,
dependencies: manifest?.packageDependencies,
optionalDependencies: manifest?.packageOptionalDependencies,
}),
};
}
@@ -121,6 +121,20 @@ describe("plugin dependency health projection", () => {
expect(report.plugins[0]?.status).toBe("error");
});
it("does not project package-local dependency health onto bundled plugins", () => {
const { fixture, reportParams } = createDependencyHealthFixture();
loaderState.registry = createDependencyHealthRegistry(fixture.pluginId, {
dependencyStatus: undefined,
origin: "bundled",
});
const report = buildPluginDiagnosticsReport(reportParams);
expect(report.plugins[0]?.dependencyStatus).toBeUndefined();
expect(report.plugins[0]?.status).toBe("loaded");
expect(report.diagnostics).toEqual([]);
});
it("preserves an existing error diagnostic when dependency health also fails", () => {
const registry = createDependencyHealthRegistry("existing-plugin-error", {
status: "error",
@@ -83,6 +83,28 @@ function expectFields(actual: Record<string, unknown>, expected: Record<string,
}
describe("buildPluginRegistrySnapshotReport", () => {
it("does not project package-local dependency health onto bundled plugins", () => {
const tempRoot = makeTempDir();
const bundledRoot = path.join(tempRoot, "bundled");
const pluginRoot = path.join(bundledRoot, "bundled-demo");
fs.mkdirSync(pluginRoot, { recursive: true });
createColdPluginFixture({
rootDir: pluginRoot,
pluginId: "bundled-demo",
packageJson: { dependencies: { "missing-build-time-dependency": "1.0.0" } },
});
const report = buildPluginRegistrySnapshotReport({
config: { plugins: { entries: { "bundled-demo": { enabled: true } } } },
env: createColdPluginHermeticEnv(tempRoot, { bundledPluginsDir: bundledRoot }),
});
const plugin = requirePlugin(report.plugins, "bundled-demo");
expectFields(plugin, { origin: "bundled", status: "loaded" });
expect(plugin.dependencyStatus).toBeUndefined();
expect(report.diagnostics).toEqual([]);
});
it("keeps recovered managed npm plugins visible when the persisted registry is stale", () => {
const tempRoot = makeTempDir();
const stateDir = path.join(tempRoot, "state");
+8 -5
View File
@@ -318,11 +318,14 @@ function buildPluginReport(
version: resolveReportedPluginVersion(plugin, params?.env),
dependencyStatus:
plugin.dependencyStatus ??
buildPluginDependencyStatus({
rootDir: plugin.rootDir,
dependencies: manifestByPluginId.get(plugin.id)?.packageDependencies,
optionalDependencies: manifestByPluginId.get(plugin.id)?.packageOptionalDependencies,
}),
(plugin.origin === "bundled"
? undefined
: buildPluginDependencyStatus({
rootDir: plugin.rootDir,
dependencies: manifestByPluginId.get(plugin.id)?.packageDependencies,
optionalDependencies: manifestByPluginId.get(plugin.id)
?.packageOptionalDependencies,
})),
}),
),
});
+4 -4
View File
@@ -4,7 +4,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import { isOpenClawOrgNpmSpec } from "../infra/npm-registry-spec.js";
import {
installedPackageNeedsOpenClawPeerLinkRepair,
readInstalledPackagePeerDependencies,
readInstalledPackageOpenClawLinkDependencies,
} from "../infra/package-update-utils.js";
import { resolveUserPath } from "../utils.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
@@ -419,8 +419,8 @@ export async function repairOpenClawPeerLinksForNpmInstalls(params: {
continue;
}
const peerDependencies = readInstalledPackagePeerDependencies(installPath);
if (!Object.hasOwn(peerDependencies, "openclaw")) {
const linkDependencies = readInstalledPackageOpenClawLinkDependencies(installPath);
if (!Object.hasOwn(linkDependencies, "openclaw")) {
continue;
}
@@ -428,7 +428,7 @@ export async function repairOpenClawPeerLinksForNpmInstalls(params: {
const warnings: string[] = [];
const peerLinkRepair = await linkOpenClawPeerDependencies({
installedDir: installPath,
peerDependencies,
peerDependencies: linkDependencies,
logger: {
info: (message) => params.logger.info?.(message),
warn: (message) => warnings.push(message),