fix: harden openclaw peer dependency installs (#70462)

This commit is contained in:
Peter Steinberger
2026-04-23 20:15:28 +01:00
parent 44820f859e
commit e93b3f60fa
3 changed files with 12 additions and 27 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -611,10 +611,8 @@ async function detectNativePackageInstallSource(packageDir: string): Promise<boo
}
/**
* After npm install completes, symlink any peerDependencies that name the host
* openclaw package into the plugin's node_modules/ directory. npm never
* materialises peerDependencies automatically, so plugins that moved openclaw
* from dependencies → peerDependencies would fail at runtime without this.
* After the staged plugin tree has been scanned, symlink the host openclaw
* package for plugins that declare it as a peer dependency.
*/
async function linkOpenClawPeerDependencies(params: {
installedDir: string;
@@ -643,31 +641,15 @@ async function linkOpenClawPeerDependencies(params: {
for (const peerName of peers) {
const linkPath = path.join(nodeModulesDir, peerName);
// Resolve the actual source for scoped packages (e.g. @scope/name).
const linkTarget = path.join(hostRoot, "node_modules", peerName);
// Check whether the package exists at the expected location inside the
// host root's node_modules. If it does not (e.g. openclaw IS the root
// package), fall back to the host root itself.
let resolvedTarget: string;
try {
await fs.access(path.join(linkTarget, "package.json"));
resolvedTarget = linkTarget;
} catch {
resolvedTarget = hostRoot;
}
try {
// Remove any existing entry (broken link or stale directory) before
// creating the new symlink so re-installs are idempotent.
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(resolvedTarget, linkPath, "junction");
params.logger.info?.(
`Linked peerDependency "${peerName}" → ${resolvedTarget}`,
);
await fs.symlink(hostRoot, linkPath, "junction");
params.logger.info?.(`Linked peerDependency "${peerName}" -> ${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) => {