mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
Reference in New Issue
Block a user