From 5d557171b3cd19fd9855dd3522b647f6aed7d570 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 6 May 2026 02:47:25 -0700 Subject: [PATCH] fix(plugins): apply npm overrides to managed roots (#78386) --- CHANGELOG.md | 1 + docs/cli/plugins.md | 2 +- docs/tools/plugin.md | 3 + src/infra/npm-managed-root.test.ts | 123 ++++++++++++++++++++++++++ src/infra/npm-managed-root.ts | 134 +++++++++++++++++++++++++++++ src/plugins/install.ts | 2 + 6 files changed, 264 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3e0e00c4c..e4f2a5f7fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,7 @@ Docs: https://docs.openclaw.ai - Codex/app-server: forward the OpenClaw workspace bootstrap block through Codex `developerInstructions` instead of `config.instructions`, so persona/style guidance reaches the behavior-shaping app-server lane. Fixes #77363. Thanks @lonexreb. - CLI/infer: pass minimal instructions to local `openai-codex/*` model probes and surface provider error details when `infer model run` returns no text. Fixes #76464. Thanks @lilesjtu. - 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: apply OpenClaw's npm security overrides inside managed external plugin npm roots so hoisted plugin dependencies inherit the host package hardening. 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/cli/plugins.md b/docs/cli/plugins.md index 249cc1315f0..0529d8fb509 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -131,7 +131,7 @@ is available, then fall back to `latest`. `plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation. - Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. + Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm roots inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too. Use `npm:` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 3833853915c..417f4772735 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -162,6 +162,9 @@ managed npm root. After npm finishes, OpenClaw verifies the installed `package-lock.json` entry still matches the resolved version and integrity. If npm writes different package metadata, the install fails and the managed package is rolled back instead of accepting a different plugin artifact. +Managed npm roots also inherit OpenClaw's package-level npm `overrides`, so +security pins that protect the packaged host also apply to hoisted external +plugin dependencies. Source checkouts are pnpm workspaces. If you clone OpenClaw to hack on bundled plugins, run `pnpm install`; OpenClaw then loads bundled plugins from diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 9aa509d555b..5fca5b1c8b7 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -1,11 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { repairManagedNpmRootOpenClawPeer, removeManagedNpmRootDependency, readManagedNpmRootInstalledDependency, + readOpenClawManagedNpmRootOverrides, resolveManagedNpmRootDependencySpec, upsertManagedNpmRootDependency, } from "./npm-managed-root.js"; @@ -71,6 +73,127 @@ describe("managed npm root", () => { }); }); + it("syncs OpenClaw-owned overrides without dropping unrelated local overrides", async () => { + const npmRoot = await makeTempRoot(); + await fs.writeFile( + path.join(npmRoot, "package.json"), + `${JSON.stringify( + { + private: true, + dependencies: { + "@openclaw/discord": "2026.5.2", + }, + overrides: { + axios: "1.13.6", + "left-pad": "1.3.0", + qs: "6.14.0", + }, + openclaw: { + managedOverrides: ["axios", "qs"], + }, + }, + null, + 2, + )}\n`, + ); + + await upsertManagedNpmRootDependency({ + npmRoot, + packageName: "@openclaw/feishu", + dependencySpec: "2026.5.4", + managedOverrides: { + axios: "1.16.0", + "node-domexception": "npm:@nolyfill/domexception@1.0.28", + }, + }); + + await expect( + fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)), + ).resolves.toEqual({ + private: true, + dependencies: { + "@openclaw/discord": "2026.5.2", + "@openclaw/feishu": "2026.5.4", + }, + overrides: { + "left-pad": "1.3.0", + axios: "1.16.0", + "node-domexception": "npm:@nolyfill/domexception@1.0.28", + }, + openclaw: { + managedOverrides: ["axios", "node-domexception"], + }, + }); + }); + + it("reads package-level npm overrides for managed plugin installs", async () => { + await expect(readOpenClawManagedNpmRootOverrides()).resolves.toMatchObject({ + axios: "1.16.0", + }); + }); + + it("resolves package-level npm overrides from packaged dist chunks", async () => { + const packageRoot = await makeTempRoot(); + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(packageRoot, "package.json"), + `${JSON.stringify( + { + name: "openclaw", + overrides: { + axios: "1.16.0", + }, + }, + null, + 2, + )}\n`, + ); + + await expect( + readOpenClawManagedNpmRootOverrides({ + moduleUrl: pathToFileURL(path.join(packageRoot, "dist", "install-AbCdEf.js")).toString(), + cwd: path.join(packageRoot, "dist"), + }), + ).resolves.toEqual({ + axios: "1.16.0", + }); + }); + + it("resolves npm override dependency references from the host package manifest", async () => { + const packageRoot = await makeTempRoot(); + await fs.writeFile( + path.join(packageRoot, "package.json"), + `${JSON.stringify( + { + name: "openclaw", + dependencies: { + "@aws-sdk/client-bedrock-runtime": "3.1024.0", + }, + optionalDependencies: { + "optional-runtime": "2.0.0", + }, + overrides: { + "@aws-sdk/client-bedrock-runtime": "$@aws-sdk/client-bedrock-runtime", + nested: { + "optional-runtime": "$optional-runtime", + }, + axios: "1.16.0", + }, + }, + null, + 2, + )}\n`, + ); + + await expect(readOpenClawManagedNpmRootOverrides({ packageRoot })).resolves.toEqual({ + "@aws-sdk/client-bedrock-runtime": "3.1024.0", + nested: { + "optional-runtime": "2.0.0", + }, + axios: "1.16.0", + }); + }); + it("does not overwrite a present malformed package manifest", async () => { const npmRoot = await makeTempRoot(); const manifestPath = path.join(npmRoot, "package.json"); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index 3d270b4d834..fac2eaad8ee 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -4,11 +4,26 @@ import { runCommandWithTimeout } from "../process/exec.js"; import type { NpmSpecResolution } from "./install-source-utils.js"; import { readJson, readJsonIfExists, writeJson } from "./json-files.js"; import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js"; +import { resolveOpenClawPackageRootSync } from "./openclaw-root.js"; import { createSafeNpmInstallEnv } from "./safe-package-install.js"; type ManagedNpmRootManifest = { private?: boolean; dependencies?: Record; + overrides?: Record; + [key: string]: unknown; +}; + +type HostPackageManifest = { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + overrides?: Record; + peerDependencies?: Record; +}; + +type ManagedNpmRootOpenClawMetadata = { + managedOverrides?: string[]; [key: string]: unknown; }; @@ -51,11 +66,108 @@ function readDependencyRecord(value: unknown): Record { return dependencies; } +function readOverrideRecord(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const overrides: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (key.trim()) { + overrides[key] = raw; + } + } + return overrides; +} + +function readManagedOverrideKeys(value: unknown): string[] { + if (!isRecord(value) || !Array.isArray(value.managedOverrides)) { + return []; + } + return value.managedOverrides.filter((key): key is string => typeof key === "string"); +} + +function buildManagedOpenClawMetadata(params: { + current: unknown; + managedOverrideKeys: string[]; +}): ManagedNpmRootOpenClawMetadata | undefined { + const metadata: ManagedNpmRootOpenClawMetadata = isRecord(params.current) + ? { ...params.current } + : {}; + if (params.managedOverrideKeys.length > 0) { + metadata.managedOverrides = params.managedOverrideKeys; + } else { + delete metadata.managedOverrides; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + async function readManagedNpmRootManifest(filePath: string): Promise { const parsed = await readJsonIfExists(filePath); return isRecord(parsed) ? { ...parsed } : {}; } +function readHostDependencySpec( + manifest: HostPackageManifest, + packageName: string, +): string | undefined { + return ( + manifest.dependencies?.[packageName] ?? + manifest.optionalDependencies?.[packageName] ?? + manifest.peerDependencies?.[packageName] ?? + manifest.devDependencies?.[packageName] + ); +} + +function resolveHostOverrideReferences(value: unknown, manifest: HostPackageManifest): unknown { + if (typeof value === "string" && value.startsWith("$")) { + return readHostDependencySpec(manifest, value.slice(1)) ?? value; + } + if (!isRecord(value)) { + return value; + } + const resolved: Record = {}; + for (const [key, nested] of Object.entries(value)) { + resolved[key] = resolveHostOverrideReferences(nested, manifest); + } + return resolved; +} + +export async function readOpenClawManagedNpmRootOverrides(params?: { + argv1?: string; + cwd?: string; + moduleUrl?: string; + packageRoot?: string | null; +}): Promise> { + const packageRoot = + params?.packageRoot ?? + resolveOpenClawPackageRootSync({ + argv1: params?.argv1 ?? process.argv[1], + moduleUrl: params?.moduleUrl ?? import.meta.url, + cwd: params?.cwd ?? process.cwd(), + }); + if (!packageRoot) { + return {}; + } + try { + const manifest = JSON.parse( + await fs.readFile(path.join(packageRoot, "package.json"), "utf8"), + ) as unknown; + if (!isRecord(manifest)) { + return {}; + } + const hostManifest = manifest as HostPackageManifest; + const overrides = readOverrideRecord(hostManifest.overrides); + return Object.fromEntries( + Object.entries(overrides).map(([key, value]) => [ + key, + resolveHostOverrideReferences(value, hostManifest), + ]), + ); + } catch { + return {}; + } +} + export function resolveManagedNpmRootDependencySpec(params: { parsedSpec: ParsedRegistryNpmSpec; resolution: NpmSpecResolution; @@ -67,11 +179,23 @@ export async function upsertManagedNpmRootDependency(params: { npmRoot: string; packageName: string; dependencySpec: string; + managedOverrides?: Record; }): Promise { await fs.mkdir(params.npmRoot, { recursive: true }); const manifestPath = path.join(params.npmRoot, "package.json"); const manifest = await readManagedNpmRootManifest(manifestPath); const dependencies = readDependencyRecord(manifest.dependencies); + const managedOverrides = readOverrideRecord(params.managedOverrides); + const managedOverrideKeys = Object.keys(managedOverrides).toSorted(); + const overrides = readOverrideRecord(manifest.overrides); + for (const key of readManagedOverrideKeys(manifest.openclaw)) { + delete overrides[key]; + } + Object.assign(overrides, managedOverrides); + const openclawMetadata = buildManagedOpenClawMetadata({ + current: manifest.openclaw, + managedOverrideKeys, + }); const next: ManagedNpmRootManifest = { ...manifest, private: true, @@ -80,6 +204,16 @@ export async function upsertManagedNpmRootDependency(params: { [params.packageName]: params.dependencySpec, }, }; + if (Object.keys(overrides).length > 0) { + next.overrides = overrides; + } else { + delete next.overrides; + } + if (openclawMetadata) { + next.openclaw = openclawMetadata; + } else { + delete next.openclaw; + } await writeJson(manifestPath, next, { trailingNewline: true }); } diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 21ad032b63d..96b44030d9f 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -11,6 +11,7 @@ import { import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js"; import { readManagedNpmRootInstalledDependency, + readOpenClawManagedNpmRootOverrides, repairManagedNpmRootOpenClawPeer, removeManagedNpmRootDependency, resolveManagedNpmRootDependencySpec, @@ -485,6 +486,7 @@ async function installPluginFromManagedNpmRoot( npmRoot, packageName: params.packageName, dependencySpec: params.dependencySpec, + managedOverrides: await readOpenClawManagedNpmRootOverrides(), }); const install = await runCommandWithTimeout( [