fix(plugins): skip managed npm peer resolution

This commit is contained in:
Vincent Koc
2026-05-06 00:51:53 -07:00
parent 5969ac8ccf
commit 3208fd2763
7 changed files with 125 additions and 5 deletions

View File

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

View File

@@ -43,7 +43,7 @@ OpenClaw uses stable per-source roots:
npm installs run in the npm root with:
```bash
npm install --prefix ~/.openclaw/npm <spec> --omit=dev --ignore-scripts --no-audit --no-fund
npm install --prefix ~/.openclaw/npm <spec> --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:

View File

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

View File

@@ -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"] : []),

View File

@@ -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<string, unknown>;
};
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");

View File

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

View File

@@ -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) {