Skip to content

Commit 8e53349

Browse files
steipetevincentkocPatrick-Erichsen
authored
fix(plugins): repair managed npm openclaw peers
Remove stale managed-root openclaw manifests, locks, hidden locks, and installed copies before npm plugin installs. Relink plugin-local openclaw peer symlinks after shared-root npm install, rollback, update, and uninstall mutations so SDK-using plugins keep resolving openclaw/plugin-sdk/*. Force safe npm commands out of inherited legacy/strict peer-dependency modes. Co-authored-by: Vincent Koc <vincentkoc@ieee.org> Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
1 parent 8cc762d commit 8e53349

13 files changed

Lines changed: 993 additions & 6 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Docs: https://docs.openclaw.ai
5959
- Plugins/update: treat official externalized bundled npm migrations and ClawHub-to-npm fallbacks as trusted source-linked installs, so prerelease-only official plugin packages can migrate from bundled builds without being rejected as unsafe prerelease resolutions. Thanks @vincentkoc.
6060
- Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc.
6161
- Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc.
62+
- Plugins/update: repair stale managed npm-root `openclaw` peer packages before plugin installs, so beta-channel official plugin updates are not downgraded by old core package-lock state. Thanks @vincentkoc.
63+
- Plugins/install: reassert managed npm plugin `openclaw` peer links after shared-root npm installs, updates, and uninstalls, so mutating one plugin does not leave previously installed SDK-using plugins unable to resolve `openclaw/plugin-sdk/*`.
6264
- Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant.
6365
- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys.
6466
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.

docs/plugins/dependency-resolution.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ the plugin package. OpenClaw scans the managed npm root before trusting the
5151
install and uses npm to remove npm-managed packages during uninstall, so hoisted
5252
runtime dependencies stay inside the managed cleanup boundary.
5353

54+
Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer
55+
dependency. OpenClaw does not let npm install a separate registry copy of the
56+
host package into the managed root, because stale host packages can affect npm
57+
peer resolution during later plugin installs. Instead, after npm finishes
58+
mutating the shared root during install, update, or uninstall, OpenClaw reasserts
59+
plugin-local `node_modules/openclaw` links for installed packages that declare
60+
the host peer.
61+
5462
git installs clone or refresh the repository, then run:
5563

5664
```bash

src/infra/npm-install-env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ const NPM_CONFIG_KEYS_TO_RESET = new Set([
99
"npm_config_include_workspace_root",
1010
"npm_config_ignore_scripts",
1111
"npm_config_location",
12+
"npm_config_legacy_peer_deps",
1213
"npm_config_prefix",
14+
"npm_config_strict_peer_deps",
1315
"npm_config_workspace",
1416
"npm_config_workspaces",
1517
]);

src/infra/npm-managed-root.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, describe, expect, it } from "vitest";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
55
import {
6+
repairManagedNpmRootOpenClawPeer,
67
removeManagedNpmRootDependency,
78
readManagedNpmRootInstalledDependency,
89
resolveManagedNpmRootDependencySpec,
@@ -11,6 +12,15 @@ import {
1112

1213
const tempDirs: string[] = [];
1314

15+
const successfulSpawn = {
16+
code: 0,
17+
stdout: "",
18+
stderr: "",
19+
signal: null,
20+
killed: false,
21+
termination: "exit" as const,
22+
};
23+
1424
async function makeTempRoot(): Promise<string> {
1525
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-managed-root-"));
1626
tempDirs.push(dir);
@@ -183,4 +193,128 @@ describe("managed npm root", () => {
183193
},
184194
});
185195
});
196+
197+
it("repairs stale managed openclaw peer state without dropping plugin packages", async () => {
198+
const npmRoot = await makeTempRoot();
199+
await fs.mkdir(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
200+
await fs.writeFile(
201+
path.join(npmRoot, "package.json"),
202+
`${JSON.stringify(
203+
{
204+
private: true,
205+
dependencies: {
206+
openclaw: "2026.5.4",
207+
"@openclaw/discord": "2026.5.4",
208+
},
209+
},
210+
null,
211+
2,
212+
)}\n`,
213+
);
214+
await fs.writeFile(
215+
path.join(npmRoot, "package-lock.json"),
216+
`${JSON.stringify(
217+
{
218+
lockfileVersion: 3,
219+
packages: {
220+
"": {
221+
dependencies: {
222+
openclaw: "2026.5.4",
223+
"@openclaw/discord": "2026.5.4",
224+
},
225+
},
226+
"node_modules/openclaw": {
227+
version: "2026.5.4",
228+
},
229+
"node_modules/@openclaw/discord": {
230+
version: "2026.5.4",
231+
},
232+
},
233+
dependencies: {
234+
openclaw: {
235+
version: "2026.5.4",
236+
},
237+
},
238+
},
239+
null,
240+
2,
241+
)}\n`,
242+
);
243+
await fs.writeFile(
244+
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
245+
`${JSON.stringify({ name: "openclaw", version: "2026.5.4" })}\n`,
246+
);
247+
await fs.mkdir(path.join(npmRoot, "node_modules", ".bin"), { recursive: true });
248+
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw"), "shim");
249+
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.cmd"), "cmd shim");
250+
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.ps1"), "ps1 shim");
251+
await fs.writeFile(
252+
path.join(npmRoot, "node_modules", ".package-lock.json"),
253+
`${JSON.stringify(
254+
{
255+
lockfileVersion: 3,
256+
packages: {
257+
"node_modules/openclaw": {
258+
version: "2026.5.4",
259+
},
260+
},
261+
},
262+
null,
263+
2,
264+
)}\n`,
265+
);
266+
267+
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
268+
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot, runCommand })).resolves.toBe(true);
269+
expect(runCommand).toHaveBeenCalledWith(
270+
[
271+
"npm",
272+
"uninstall",
273+
"--loglevel=error",
274+
"--ignore-scripts",
275+
"--no-audit",
276+
"--no-fund",
277+
"--prefix",
278+
".",
279+
"openclaw",
280+
],
281+
expect.objectContaining({
282+
cwd: npmRoot,
283+
}),
284+
);
285+
286+
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
287+
dependencies?: Record<string, string>;
288+
};
289+
expect(manifest.dependencies).toEqual({
290+
"@openclaw/discord": "2026.5.4",
291+
});
292+
const lockfile = JSON.parse(
293+
await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8"),
294+
) as {
295+
packages?: Record<string, { dependencies?: Record<string, string>; version?: string }>;
296+
dependencies?: Record<string, unknown>;
297+
};
298+
expect(lockfile.packages?.[""]?.dependencies).toEqual({
299+
"@openclaw/discord": "2026.5.4",
300+
});
301+
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
302+
expect(lockfile.packages?.["node_modules/@openclaw/discord"]?.version).toBe("2026.5.4");
303+
expect(lockfile.dependencies?.openclaw).toBeUndefined();
304+
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
305+
code: "ENOENT",
306+
});
307+
for (const binName of ["openclaw", "openclaw.cmd", "openclaw.ps1"]) {
308+
await expect(
309+
fs.lstat(path.join(npmRoot, "node_modules", ".bin", binName)),
310+
).rejects.toMatchObject({
311+
code: "ENOENT",
312+
});
313+
}
314+
await expect(
315+
fs.lstat(path.join(npmRoot, "node_modules", ".package-lock.json")),
316+
).rejects.toMatchObject({
317+
code: "ENOENT",
318+
});
319+
});
186320
});

src/infra/npm-managed-root.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3+
import { runCommandWithTimeout } from "../process/exec.js";
34
import type { NpmSpecResolution } from "./install-source-utils.js";
45
import { readJson, readJsonIfExists, writeJson } from "./json-files.js";
56
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
7+
import { createSafeNpmInstallEnv } from "./safe-package-install.js";
68

79
type ManagedNpmRootManifest = {
810
private?: boolean;
@@ -16,6 +18,18 @@ export type ManagedNpmRootInstalledDependency = {
1618
resolved?: string;
1719
};
1820

21+
type ManagedNpmRootLockfile = {
22+
packages?: Record<string, unknown>;
23+
dependencies?: Record<string, unknown>;
24+
[key: string]: unknown;
25+
};
26+
27+
type ManagedNpmRootLogger = {
28+
warn?: (message: string) => void;
29+
};
30+
31+
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
32+
1933
function isRecord(value: unknown): value is Record<string, unknown> {
2034
return typeof value === "object" && value !== null && !Array.isArray(value);
2135
}
@@ -69,6 +83,168 @@ export async function upsertManagedNpmRootDependency(params: {
6983
await writeJson(manifestPath, next, { trailingNewline: true });
7084
}
7185

86+
export async function repairManagedNpmRootOpenClawPeer(params: {
87+
npmRoot: string;
88+
timeoutMs?: number;
89+
logger?: ManagedNpmRootLogger;
90+
runCommand?: ManagedNpmRootRunCommand;
91+
}): Promise<boolean> {
92+
await fs.mkdir(params.npmRoot, { recursive: true });
93+
94+
const manifestPath = path.join(params.npmRoot, "package.json");
95+
const manifest = await readManagedNpmRootManifest(manifestPath);
96+
const dependencies = readDependencyRecord(manifest.dependencies);
97+
const hasManifestDependency = "openclaw" in dependencies;
98+
const hasLockDependency = await managedNpmRootLockfileHasOpenClawPeer(params.npmRoot);
99+
const hasPackageDir = await pathExists(path.join(params.npmRoot, "node_modules", "openclaw"));
100+
if (!hasManifestDependency && !hasLockDependency && !hasPackageDir) {
101+
return false;
102+
}
103+
104+
const command = params.runCommand ?? runCommandWithTimeout;
105+
const npmArgs = hasManifestDependency
106+
? [
107+
"npm",
108+
"uninstall",
109+
"--loglevel=error",
110+
"--ignore-scripts",
111+
"--no-audit",
112+
"--no-fund",
113+
"--prefix",
114+
".",
115+
"openclaw",
116+
]
117+
: [
118+
"npm",
119+
"prune",
120+
"--loglevel=error",
121+
"--ignore-scripts",
122+
"--no-audit",
123+
"--no-fund",
124+
"--prefix",
125+
".",
126+
];
127+
try {
128+
const result = await command(npmArgs, {
129+
cwd: params.npmRoot,
130+
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
131+
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
132+
});
133+
if (result.code !== 0) {
134+
params.logger?.warn?.(
135+
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${result.stderr.trim() || result.stdout.trim()}`,
136+
);
137+
}
138+
} catch (error) {
139+
params.logger?.warn?.(
140+
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${String(error)}`,
141+
);
142+
}
143+
144+
await scrubManagedNpmRootOpenClawPeer({ npmRoot: params.npmRoot });
145+
return true;
146+
}
147+
148+
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
149+
const lockPath = path.join(npmRoot, "package-lock.json");
150+
try {
151+
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
152+
if (isRecord(parsed.packages)) {
153+
const rootPackage = parsed.packages[""];
154+
if (
155+
isRecord(rootPackage) &&
156+
isRecord(rootPackage.dependencies) &&
157+
"openclaw" in rootPackage.dependencies
158+
) {
159+
return true;
160+
}
161+
if ("node_modules/openclaw" in parsed.packages) {
162+
return true;
163+
}
164+
}
165+
return isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies;
166+
} catch (err) {
167+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
168+
return false;
169+
}
170+
throw err;
171+
}
172+
}
173+
174+
async function pathExists(filePath: string): Promise<boolean> {
175+
return await fs
176+
.lstat(filePath)
177+
.then(() => true)
178+
.catch((err: NodeJS.ErrnoException) => {
179+
if (err.code === "ENOENT") {
180+
return false;
181+
}
182+
throw err;
183+
});
184+
}
185+
186+
async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise<void> {
187+
const manifestPath = path.join(params.npmRoot, "package.json");
188+
const manifest = await readManagedNpmRootManifest(manifestPath);
189+
const dependencies = readDependencyRecord(manifest.dependencies);
190+
if ("openclaw" in dependencies) {
191+
const { openclaw: _removed, ...nextDependencies } = dependencies;
192+
await fs.writeFile(
193+
manifestPath,
194+
`${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`,
195+
"utf8",
196+
);
197+
}
198+
199+
const lockPath = path.join(params.npmRoot, "package-lock.json");
200+
try {
201+
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
202+
let lockChanged = false;
203+
if (isRecord(parsed.packages)) {
204+
const rootPackage = parsed.packages[""];
205+
if (isRecord(rootPackage) && isRecord(rootPackage.dependencies)) {
206+
const dependencies = { ...rootPackage.dependencies };
207+
if ("openclaw" in dependencies) {
208+
delete dependencies.openclaw;
209+
parsed.packages[""] = { ...rootPackage, dependencies };
210+
lockChanged = true;
211+
}
212+
}
213+
if ("node_modules/openclaw" in parsed.packages) {
214+
delete parsed.packages["node_modules/openclaw"];
215+
lockChanged = true;
216+
}
217+
}
218+
if (isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies) {
219+
const dependencies = { ...parsed.dependencies };
220+
delete dependencies.openclaw;
221+
parsed.dependencies = dependencies;
222+
lockChanged = true;
223+
}
224+
if (lockChanged) {
225+
await fs.writeFile(lockPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
226+
}
227+
} catch (err) {
228+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
229+
throw err;
230+
}
231+
}
232+
233+
const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
234+
if (await pathExists(openclawPackageDir)) {
235+
await fs.rm(openclawPackageDir, { recursive: true, force: true });
236+
}
237+
const binDir = path.join(params.npmRoot, "node_modules", ".bin");
238+
await Promise.all(
239+
["openclaw", "openclaw.cmd", "openclaw.ps1"].map((binName) =>
240+
fs.rm(path.join(binDir, binName), { force: true }),
241+
),
242+
);
243+
await fs.rm(path.join(params.npmRoot, "node_modules", ".package-lock.json"), {
244+
force: true,
245+
});
246+
}
247+
72248
export async function readManagedNpmRootInstalledDependency(params: {
73249
npmRoot: string;
74250
packageName: string;

0 commit comments

Comments
 (0)