diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d9e5eefa5..32d4738896b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Dependencies: override transitive `ip-address` to `10.2.0` so the runtime lockfile no longer includes the vulnerable `10.1.0` build flagged by Dependabot alert 109. Thanks @vincentkoc. +- Plugins/install: skip npm peer resolution in managed plugin roots so installing peer-based plugins such as Opik cannot pull a stale registry `openclaw` copy beside Codex/Discord/WhatsApp and trigger `ERESOLVE`. Thanks @vincentkoc. - Feishu: hydrate missing native topic starter thread IDs before session routing so first turns and follow-ups stay in the same topic session. Fixes #78262. Thanks @joeyzenghuan. - LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316. - Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949) Thanks @keshavbotagent. diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index fd7a4364dfb..ae9f6a367c0 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -43,7 +43,7 @@ OpenClaw uses stable per-source roots: npm installs run in the npm root with: ```bash -npm install --prefix ~/.openclaw/npm --omit=dev --ignore-scripts --no-audit --no-fund +npm install --prefix ~/.openclaw/npm --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund ``` npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside @@ -54,10 +54,10 @@ runtime dependencies stay inside the managed cleanup boundary. Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer dependency. OpenClaw does not let npm install a separate registry copy of the host package into the managed root, because stale host packages can affect npm -peer resolution during later plugin installs. Instead, after npm finishes -mutating the shared root during install, update, or uninstall, OpenClaw reasserts +peer resolution during later plugin installs. Managed npm installs skip npm peer +resolution/materialization for the shared root and OpenClaw reasserts plugin-local `node_modules/openclaw` links for installed packages that declare -the host peer. +the host peer after install, update, or uninstall. git installs clone or refresh the repository, then run: diff --git a/src/infra/safe-package-install.test.ts b/src/infra/safe-package-install.test.ts index 9c64e398435..f75fd13ba00 100644 --- a/src/infra/safe-package-install.test.ts +++ b/src/infra/safe-package-install.test.ts @@ -6,6 +6,8 @@ describe("safe npm install helpers", () => { expect( createSafeNpmInstallArgs({ omitDev: true, + omitPeer: true, + legacyPeerDeps: true, ignoreWorkspaces: true, loglevel: "error", noAudit: true, @@ -14,6 +16,8 @@ describe("safe npm install helpers", () => { ).toEqual([ "install", "--omit=dev", + "--omit=peer", + "--legacy-peer-deps", "--loglevel=error", "--ignore-scripts", "--workspaces=false", diff --git a/src/infra/safe-package-install.ts b/src/infra/safe-package-install.ts index 001f3353019..573698051f3 100644 --- a/src/infra/safe-package-install.ts +++ b/src/infra/safe-package-install.ts @@ -10,10 +10,12 @@ type SafeNpmInstallEnvOptions = NpmProjectInstallEnvOptions & { type SafeNpmInstallArgsOptions = { ignoreWorkspaces?: boolean; + legacyPeerDeps?: boolean; loglevel?: "error" | "silent"; noAudit?: boolean; noFund?: boolean; omitDev?: boolean; + omitPeer?: boolean; }; export function createSafeNpmInstallEnv( @@ -47,6 +49,8 @@ export function createSafeNpmInstallArgs(options: SafeNpmInstallArgsOptions = {} return [ "install", ...(options.omitDev ? ["--omit=dev"] : []), + ...(options.omitPeer ? ["--omit=peer"] : []), + ...(options.legacyPeerDeps ? ["--legacy-peer-deps"] : []), ...(options.loglevel ? [`--loglevel=${options.loglevel}`] : []), "--ignore-scripts", ...(options.ignoreWorkspaces ? ["--workspaces=false"] : []), diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index bc6dc747cbe..770101d4be6 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -360,6 +360,109 @@ describe("installPluginFromNpmSpec e2e", () => { ).resolves.toBe(true); }); + it("omits peers when installing beside an existing host-peer plugin", async () => { + const rootDir = await makeTempDir("npm-plugin-sibling-peer-e2e"); + const npmRoot = path.join(rootDir, "managed-npm"); + const codexName = `codex-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; + const opikName = `opik-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; + const registry = await startStaticRegistry([ + { + packageName: codexName, + latest: "1.0.0", + versions: [ + await packPlugin({ + packageName: codexName, + peerDependencies: { openclaw: ">=2026.5.5-beta.2" }, + peerDependenciesMeta: { openclaw: { optional: true } }, + pluginId: codexName, + version: "1.0.0", + rootDir, + }), + ], + }, + { + packageName: opikName, + latest: "1.0.0", + versions: [ + await packPlugin({ + packageName: opikName, + peerDependencies: { openclaw: ">=2026.3.2" }, + peerDependenciesMeta: {}, + pluginId: opikName, + version: "1.0.0", + rootDir, + }), + ], + }, + { + packageName: "openclaw", + latest: "2026.5.4", + versions: [ + await packPlugin({ + packageName: "openclaw", + pluginId: "registry-openclaw-copy", + version: "2026.5.4", + rootDir, + }), + ], + }, + ]); + process.env.NPM_CONFIG_REGISTRY = registry; + process.env.npm_config_registry = registry; + + await fs.mkdir(npmRoot, { recursive: true }); + await fs.writeFile( + path.join(npmRoot, "package.json"), + `${JSON.stringify({ private: true, dependencies: { [codexName]: "1.0.0" } }, null, 2)}\n`, + "utf8", + ); + await execFileAsync( + "npm", + ["install", "--omit=peer", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"], + { + cwd: npmRoot, + env: { + ...process.env, + NPM_CONFIG_REGISTRY: registry, + NPM_CONFIG_LEGACY_PEER_DEPS: "false", + NPM_CONFIG_STRICT_PEER_DEPS: "false", + npm_config_registry: registry, + npm_config_legacy_peer_deps: "false", + npm_config_strict_peer_deps: "false", + }, + timeout: 120_000, + }, + ); + + const result = await installPluginFromNpmSpec({ + spec: `${opikName}@1.0.0`, + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + timeoutMs: 120_000, + }); + if (!result.ok) { + throw new Error(result.error); + } + + const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as { + packages?: Record; + }; + expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined(); + await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect( + fs + .lstat(path.join(npmRoot, "node_modules", codexName, "node_modules", "openclaw")) + .then((stat) => stat.isSymbolicLink()), + ).resolves.toBe(true); + await expect( + fs + .lstat(path.join(npmRoot, "node_modules", opikName, "node_modules", "openclaw")) + .then((stat) => stat.isSymbolicLink()), + ).resolves.toBe(true); + }); + it("relinks managed npm sibling openclaw peers after later plugin installs", async () => { const rootDir = await makeTempDir("npm-plugin-peer-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 15c12ff4890..998e67785be 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -50,6 +50,8 @@ function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string "npm", "install", "--omit=dev", + "--omit=peer", + "--legacy-peer-deps", "--loglevel=error", "--ignore-scripts", "--no-audit", diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 7517dc2944b..8500169fc87 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1372,6 +1372,8 @@ export async function installPluginFromNpmSpec( "npm", ...createSafeNpmInstallArgs({ omitDev: true, + omitPeer: true, + legacyPeerDeps: true, loglevel: "error", noAudit: true, noFund: true, @@ -1382,7 +1384,11 @@ export async function installPluginFromNpmSpec( { cwd: npmRoot, timeoutMs: Math.max(timeoutMs, 300_000), - env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }), + env: createSafeNpmInstallEnv(process.env, { + legacyPeerDeps: true, + packageLock: true, + quiet: true, + }), }, ); if (install.code !== 0) {