From b2da7aa106dc8fbdc8191b9fc457038dc1ff2178 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 18:58:33 -0700 Subject: [PATCH] fix(plugins): link host declared as direct dependency (#117738) --- src/plugins/install-installed-package.ts | 2 +- src/plugins/install.test.ts | 128 ++++++++++++++--------- 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/src/plugins/install-installed-package.ts b/src/plugins/install-installed-package.ts index f9f6e0940b26..0bcad786ce62 100644 --- a/src/plugins/install-installed-package.ts +++ b/src/plugins/install-installed-package.ts @@ -196,7 +196,7 @@ export async function validatePackagePluginInstallSource(params: { ? { setup: ocManifestResult.manifest.setup } : {}), hasRuntimeDependencies: hasPackageRuntimeDependencies(manifest), - peerDependencies: manifest.peerDependencies ?? {}, + peerDependencies: { ...manifest.dependencies, ...manifest.peerDependencies }, }, }; } diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 83d50d0d956d..f4f38dc39b1b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -3820,6 +3820,26 @@ describe("installPluginFromDir", () => { describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { const resolveRootMock = vi.mocked(resolveOpenClawPackageRootSync); + const hostDependencyDeclarations: Array<{ + declaration: string; + peerDependencies: Record; + dependencies?: Record; + }> = [ + { + declaration: "peerDependencies", + peerDependencies: { openclaw: "*" }, + }, + { + declaration: "dependencies", + peerDependencies: {}, + dependencies: { openclaw: "*" }, + }, + { + declaration: "dependencies alongside an unrelated peer dependency", + peerDependencies: { "unrelated-host": "^1.0.0" }, + dependencies: { openclaw: "*" }, + }, + ]; function writePluginWithPeerDeps( pluginDir: string, @@ -3841,27 +3861,29 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n", "utf-8"); } - it("creates a node_modules/openclaw symlink when peerDependencies declares openclaw", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - const fakeHostRoot = suiteTempRootTracker.makeTempDir(); - const run = vi.mocked(runCommandWithTimeout); - resolveRootMock.mockReturnValue(fakeHostRoot); + it.each(hostDependencyDeclarations)( + "creates a host-targeted node_modules/openclaw symlink for $declaration", + async ({ peerDependencies, dependencies }) => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const fakeHostRoot = suiteTempRootTracker.makeTempDir(); + const run = vi.mocked(runCommandWithTimeout); + resolveRootMock.mockReturnValue(fakeHostRoot); - writePluginWithPeerDeps(pluginDir, { openclaw: "*" }); + writePluginWithPeerDeps(pluginDir, peerDependencies, dependencies); - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(true); - if (!result.ok) { - return; - } + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } - const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw"); - const stat = fs.lstatSync(symlinkPath); - expect(stat.isSymbolicLink()).toBe(true); - expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot)); - expect(run).not.toHaveBeenCalled(); - }); + const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw"); + expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot)); + expect(run).not.toHaveBeenCalled(); + }, + ); it("keeps the openclaw peer symlink when a local plugin already has dependencies", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); @@ -3890,33 +3912,36 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); - it("replaces a copied local openclaw package with the host peer symlink", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - const fakeHostRoot = suiteTempRootTracker.makeTempDir(); - resolveRootMock.mockReturnValue(fakeHostRoot); + it.each(hostDependencyDeclarations)( + "replaces a copied local openclaw package with the host symlink for $declaration", + async ({ peerDependencies, dependencies }) => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const fakeHostRoot = suiteTempRootTracker.makeTempDir(); + resolveRootMock.mockReturnValue(fakeHostRoot); - writePluginWithPeerDeps(pluginDir, { openclaw: "*" }); - fs.mkdirSync(path.join(pluginDir, "node_modules", "openclaw"), { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "node_modules", "openclaw", "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.5.31" }), - "utf-8", - ); + writePluginWithPeerDeps(pluginDir, peerDependencies, dependencies); + fs.mkdirSync(path.join(pluginDir, "node_modules", "openclaw"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "node_modules", "openclaw", "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.31" }), + "utf-8", + ); - const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(true); - expect(warnings).toHaveLength(0); - if (!result.ok) { - return; - } + expect(result.ok).toBe(true); + expect(warnings).toHaveLength(0); + if (!result.ok) { + return; + } - const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw"); - expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); - expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot)); - }); + const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw"); + expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot)); + }, + ); - it("does not create a symlink when peerDependencies is empty", async () => { + it("does not create a symlink when neither dependency map declares openclaw", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); resolveRootMock.mockReturnValue(suiteTempRootTracker.makeTempDir()); @@ -3961,19 +3986,22 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); }); - it("rejects when resolveOpenClawPackageRootSync returns null", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - resolveRootMock.mockReturnValue(null); + it.each(hostDependencyDeclarations)( + "rejects $declaration when the host package root cannot be resolved", + async ({ peerDependencies, dependencies }) => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + resolveRootMock.mockReturnValue(null); - writePluginWithPeerDeps(pluginDir, { openclaw: "*" }); + writePluginWithPeerDeps(pluginDir, peerDependencies, dependencies); - const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toContain("plugin-local node_modules/openclaw link"); - } - expectWarningIncludes(warnings, "Could not locate openclaw package root"); - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("plugin-local node_modules/openclaw link"); + } + expectWarningIncludes(warnings, "Could not locate openclaw package root"); + }, + ); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */