diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ec0e05230..d64ddf99ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/system events: route async exec-event completion replies through the persisted session delivery context, so long-running command results return to the originating channel instead of being dropped when live origin metadata is missing. (#70258) Thanks @wzfukui. - QA channel/security: reject non-HTTP(S) inbound attachment URLs before media fetch, and log rejected schemes so suspicious or misconfigured payloads are visible during debugging. (#70708) Thanks @vincentkoc. +- Plugins/install: link the host OpenClaw package into external plugins that declare `openclaw` as a peer dependency, so peer-only plugin SDK imports resolve after install without bundling a duplicate host package. (#70462) Thanks @anishesg. - Teams/security: require shared Bot Framework audience tokens to name the configured Teams app via verified `appid` or `azp`, blocking cross-bot token replay on the global audience. (#70724) Thanks @vincentkoc. - Plugins/startup: resolve bundled plugin Jiti loads relative to the target plugin module instead of the central loader, so Bun global installs no longer hang while discovering bundled image providers. (#70073) Thanks @yidianyiko. - Anthropic/CLI security: stop Claude CLI backend defaults from forcing `bypassPermissions`, and strip malformed permission-mode overrides instead of silently falling back to a bypass. (#70723) Thanks @vincentkoc. diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 88c765fbf3f..c8e14a0e275 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -3,13 +3,13 @@ import path from "node:path"; import * as tar from "tar"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { safePathSegmentHashed } from "../infra/install-safe-path.js"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { expectSingleNpmInstallIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; import { expectInstallUsesIgnoreScripts } from "../test-utils/npm-spec-install-test-helpers.js"; import { initializeGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createMockPluginRegistry } from "./hooks.test-helpers.js"; import * as installSecurityScan from "./install-security-scan.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { installPluginFromArchive, installPluginFromDir, @@ -2380,6 +2380,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { 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); writePluginWithPeerDeps(pluginDir, { openclaw: "*" }); @@ -2395,6 +2396,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { const stat = fs.lstatSync(symlinkPath); expect(stat.isSymbolicLink()).toBe(true); expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot)); + expect(run).not.toHaveBeenCalled(); }); it("does not create a symlink when peerDependencies is empty", async () => { @@ -2415,7 +2417,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { expect(fs.existsSync(symlinkPath)).toBe(false); }); - it("is idempotent — re-installing replaces an existing symlink without error", async () => { + it("is idempotent - re-installing replaces an existing symlink without error", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); const fakeHostRoot = suiteTempRootTracker.makeTempDir(); resolveRootMock.mockReturnValue(fakeHostRoot); @@ -2426,7 +2428,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { const { result: first } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(first.ok).toBe(true); - // Second install (update mode) — should replace symlink, not throw + // Second install (update mode) should replace symlink, not throw. const { result: second, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir, diff --git a/src/plugins/install.ts b/src/plugins/install.ts index a886863c4e7..1b565a86b29 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -611,10 +611,8 @@ async function detectNativePackageInstallSource(packageDir: string): Promise ${hostRoot}`); } catch (err) { - params.logger.warn?.( - `Failed to symlink peerDependency "${peerName}": ${String(err)}`, - ); + params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`); } } } @@ -820,7 +802,7 @@ async function installPluginFromPackageDir( mode: targetResult.target.effectiveMode, dryRun, copyErrorPrefix: "failed to copy plugin", - hasDeps: Object.keys(deps).length > 0 || Object.keys(peerDeps).length > 0, + hasDeps: Object.keys(deps).length > 0, depsLogMessage: "Installing plugin dependencies…", nameEncoder: encodePluginInstallDirName, afterCopy: async (installedDir) => {