From 355295bfef4abadeb94286dce2b911d12531b98b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 21:27:06 +0800 Subject: [PATCH] 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 --- src/infra/package-update-utils.ts | 19 +++-- .../install-security-scan.runtime.test.ts | 73 ++++++++++++++++++- src/plugins/install-security-scan.runtime.ts | 19 +++++ src/plugins/install-shared.ts | 2 +- src/plugins/plugin-peer-link.test.ts | 27 +++++++ src/plugins/plugin-peer-link.ts | 27 ++++--- src/plugins/status-snapshot.ts | 13 ++-- src/plugins/status.dependency-health.test.ts | 14 ++++ src/plugins/status.registry-snapshot.test.ts | 22 ++++++ src/plugins/status.ts | 13 ++-- src/plugins/update-config.ts | 8 +- 11 files changed, 200 insertions(+), 37 deletions(-) diff --git a/src/infra/package-update-utils.ts b/src/infra/package-update-utils.ts index 24870fd97d4f..30f67c82ba7a 100644 --- a/src/infra/package-update-utils.ts +++ b/src/infra/package-update-utils.ts @@ -44,22 +44,21 @@ export async function readInstalledPackageVersion(dir: string): Promise { +/** Read the installed package declaration that requires the OpenClaw host link. */ +export function readInstalledPackageOpenClawLinkDependencies(dir: string): Record { 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; } diff --git a/src/plugins/install-security-scan.runtime.test.ts b/src/plugins/install-security-scan.runtime.test.ts index 4c509d8a402e..98959e839f1f 100644 --- a/src/plugins/install-security-scan.runtime.test.ts +++ b/src/plugins/install-security-scan.runtime.test.ts @@ -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[] = []; diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index bd1f8f16d6c2..c3af7d284e2a 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -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 { const packageDir = path.join(params.packageDir, "node_modules", params.dependencyName); let stats: Awaited>; @@ -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 { 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); diff --git a/src/plugins/install-shared.ts b/src/plugins/install-shared.ts index cfd2b4b6621e..5154e1fe6c51 100644 --- a/src/plugins/install-shared.ts +++ b/src/plugins/install-shared.ts @@ -34,7 +34,7 @@ export type PluginInstallRuntime = Awaited { 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"); diff --git a/src/plugins/plugin-peer-link.ts b/src/plugins/plugin-peer-link.ts index 4c7408125a45..79c2892bdc9c 100644 --- a/src/plugins/plugin-peer-link.ts +++ b/src/plugins/plugin-peer-link.ts @@ -43,11 +43,16 @@ function readStringRecord(value: unknown): Record { return record; } -async function readPackagePeerDependencies(packageDir: string): Promise> { +async function readPackageOpenClawLinkDependencies( + packageDir: string, +): Promise> { 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 } /** - * 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; + let openClawLinkDependencies: Record; 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; + let openClawLinkDependencies: Record; 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; diff --git a/src/plugins/status-snapshot.ts b/src/plugins/status-snapshot.ts index d1b3c70ce703..c89544b8a381 100644 --- a/src/plugins/status-snapshot.ts +++ b/src/plugins/status-snapshot.ts @@ -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, + }), }; } diff --git a/src/plugins/status.dependency-health.test.ts b/src/plugins/status.dependency-health.test.ts index 2d03b9a8d9df..455615fb0a68 100644 --- a/src/plugins/status.dependency-health.test.ts +++ b/src/plugins/status.dependency-health.test.ts @@ -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", diff --git a/src/plugins/status.registry-snapshot.test.ts b/src/plugins/status.registry-snapshot.test.ts index d87ac85cc922..cca24f040038 100644 --- a/src/plugins/status.registry-snapshot.test.ts +++ b/src/plugins/status.registry-snapshot.test.ts @@ -83,6 +83,28 @@ function expectFields(actual: Record, expected: Record { + 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"); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index f3cd7ffa12d3..1f00f7de4908 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -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, + })), }), ), }); diff --git a/src/plugins/update-config.ts b/src/plugins/update-config.ts index 36b7c0cd2d86..2143be40d0e4 100644 --- a/src/plugins/update-config.ts +++ b/src/plugins/update-config.ts @@ -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),