Skip to content

Commit 97c6830

Browse files
committed
fix(plugins): repair stale managed openclaw peers
1 parent ae9f779 commit 97c6830

5 files changed

Lines changed: 251 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
5757
- 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.
5858
- Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc.
5959
- 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.
60+
- 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.
6061
- 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.
6162
- 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.
6263
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from "node:os";
33
import path from "node:path";
44
import { afterEach, describe, expect, it } from "vitest";
55
import {
6+
repairManagedNpmRootOpenClawPeer,
67
removeManagedNpmRootDependency,
78
readManagedNpmRootInstalledDependency,
89
resolveManagedNpmRootDependencySpec,
@@ -167,4 +168,80 @@ describe("managed npm root", () => {
167168
},
168169
});
169170
});
171+
172+
it("repairs stale managed openclaw peer state without dropping plugin packages", async () => {
173+
const npmRoot = await makeTempRoot();
174+
await fs.mkdir(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
175+
await fs.writeFile(
176+
path.join(npmRoot, "package.json"),
177+
`${JSON.stringify(
178+
{
179+
private: true,
180+
dependencies: {
181+
openclaw: "2026.5.4",
182+
"@openclaw/discord": "2026.5.4",
183+
},
184+
},
185+
null,
186+
2,
187+
)}\n`,
188+
);
189+
await fs.writeFile(
190+
path.join(npmRoot, "package-lock.json"),
191+
`${JSON.stringify(
192+
{
193+
lockfileVersion: 3,
194+
packages: {
195+
"": {
196+
dependencies: {
197+
openclaw: "2026.5.4",
198+
"@openclaw/discord": "2026.5.4",
199+
},
200+
},
201+
"node_modules/openclaw": {
202+
version: "2026.5.4",
203+
},
204+
"node_modules/@openclaw/discord": {
205+
version: "2026.5.4",
206+
},
207+
},
208+
dependencies: {
209+
openclaw: {
210+
version: "2026.5.4",
211+
},
212+
},
213+
},
214+
null,
215+
2,
216+
)}\n`,
217+
);
218+
await fs.writeFile(
219+
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
220+
`${JSON.stringify({ name: "openclaw", version: "2026.5.4" })}\n`,
221+
);
222+
223+
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot })).resolves.toBe(true);
224+
225+
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
226+
dependencies?: Record<string, string>;
227+
};
228+
expect(manifest.dependencies).toEqual({
229+
"@openclaw/discord": "2026.5.4",
230+
});
231+
const lockfile = JSON.parse(
232+
await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8"),
233+
) as {
234+
packages?: Record<string, { dependencies?: Record<string, string>; version?: string }>;
235+
dependencies?: Record<string, unknown>;
236+
};
237+
expect(lockfile.packages?.[""]?.dependencies).toEqual({
238+
"@openclaw/discord": "2026.5.4",
239+
});
240+
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
241+
expect(lockfile.packages?.["node_modules/@openclaw/discord"]?.version).toBe("2026.5.4");
242+
expect(lockfile.dependencies?.openclaw).toBeUndefined();
243+
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
244+
code: "ENOENT",
245+
});
246+
});
170247
});

src/infra/npm-managed-root.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export type ManagedNpmRootInstalledDependency = {
1515
resolved?: string;
1616
};
1717

18+
type ManagedNpmRootLockfile = {
19+
packages?: Record<string, unknown>;
20+
dependencies?: Record<string, unknown>;
21+
[key: string]: unknown;
22+
};
23+
1824
function isRecord(value: unknown): value is Record<string, unknown> {
1925
return typeof value === "object" && value !== null && !Array.isArray(value);
2026
}
@@ -75,6 +81,78 @@ export async function upsertManagedNpmRootDependency(params: {
7581
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
7682
}
7783

84+
export async function repairManagedNpmRootOpenClawPeer(params: {
85+
npmRoot: string;
86+
}): Promise<boolean> {
87+
let changed = false;
88+
89+
await fs.mkdir(params.npmRoot, { recursive: true });
90+
const manifestPath = path.join(params.npmRoot, "package.json");
91+
const manifest = await readManagedNpmRootManifest(manifestPath);
92+
const dependencies = readDependencyRecord(manifest.dependencies);
93+
if ("openclaw" in dependencies) {
94+
const { openclaw: _removed, ...nextDependencies } = dependencies;
95+
await fs.writeFile(
96+
manifestPath,
97+
`${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`,
98+
"utf8",
99+
);
100+
changed = true;
101+
}
102+
103+
const lockPath = path.join(params.npmRoot, "package-lock.json");
104+
try {
105+
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
106+
let lockChanged = false;
107+
if (isRecord(parsed.packages)) {
108+
const rootPackage = parsed.packages[""];
109+
if (isRecord(rootPackage) && isRecord(rootPackage.dependencies)) {
110+
const dependencies = { ...rootPackage.dependencies };
111+
if ("openclaw" in dependencies) {
112+
delete dependencies.openclaw;
113+
parsed.packages[""] = { ...rootPackage, dependencies };
114+
lockChanged = true;
115+
}
116+
}
117+
if ("node_modules/openclaw" in parsed.packages) {
118+
delete parsed.packages["node_modules/openclaw"];
119+
lockChanged = true;
120+
}
121+
}
122+
if (isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies) {
123+
const dependencies = { ...parsed.dependencies };
124+
delete dependencies.openclaw;
125+
parsed.dependencies = dependencies;
126+
lockChanged = true;
127+
}
128+
if (lockChanged) {
129+
await fs.writeFile(lockPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
130+
changed = true;
131+
}
132+
} catch (err) {
133+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
134+
throw err;
135+
}
136+
}
137+
138+
const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
139+
const openclawPackageDirExists = await fs
140+
.lstat(openclawPackageDir)
141+
.then(() => true)
142+
.catch((err: NodeJS.ErrnoException) => {
143+
if (err.code === "ENOENT") {
144+
return false;
145+
}
146+
throw err;
147+
});
148+
if (openclawPackageDirExists) {
149+
await fs.rm(openclawPackageDir, { recursive: true, force: true });
150+
changed = true;
151+
}
152+
153+
return changed;
154+
}
155+
78156
export async function readManagedNpmRootInstalledDependency(params: {
79157
npmRoot: string;
80158
packageName: string;

src/plugins/install.npm-spec.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,94 @@ describe("installPluginFromNpmSpec", () => {
507507
},
508508
);
509509

510+
it("repairs stale managed openclaw root packages before npm plugin installs", async () => {
511+
const stateDir = suiteTempRootTracker.makeTempDir();
512+
const npmRoot = path.join(stateDir, "npm");
513+
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
514+
fs.writeFileSync(
515+
path.join(npmRoot, "package.json"),
516+
JSON.stringify(
517+
{
518+
private: true,
519+
dependencies: {
520+
openclaw: "2026.5.4",
521+
},
522+
},
523+
null,
524+
2,
525+
),
526+
"utf-8",
527+
);
528+
fs.writeFileSync(
529+
path.join(npmRoot, "package-lock.json"),
530+
`${JSON.stringify(
531+
{
532+
lockfileVersion: 3,
533+
packages: {
534+
"": {
535+
dependencies: {
536+
openclaw: "2026.5.4",
537+
},
538+
},
539+
"node_modules/openclaw": {
540+
version: "2026.5.4",
541+
resolved: "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.4.tgz",
542+
},
543+
},
544+
dependencies: {
545+
openclaw: {
546+
version: "2026.5.4",
547+
},
548+
},
549+
},
550+
null,
551+
2,
552+
)}\n`,
553+
"utf-8",
554+
);
555+
fs.writeFileSync(
556+
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
557+
JSON.stringify({
558+
name: "openclaw",
559+
version: "2026.5.4",
560+
}),
561+
"utf-8",
562+
);
563+
564+
mockNpmViewAndInstall({
565+
spec: "@openclaw/discord@beta",
566+
packageName: "@openclaw/discord",
567+
version: "2026.5.5-beta.1",
568+
pluginId: "discord",
569+
npmRoot,
570+
peerDependencies: { openclaw: ">=2026.5.5-beta.1" },
571+
expectedDependencySpec: "2026.5.5-beta.1",
572+
});
573+
574+
const result = await installPluginFromNpmSpec({
575+
spec: "@openclaw/discord@beta",
576+
npmDir: npmRoot,
577+
logger: { info: () => {}, warn: () => {} },
578+
});
579+
580+
expect(result.ok).toBe(true);
581+
const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as {
582+
dependencies?: Record<string, string>;
583+
};
584+
expect(manifest.dependencies).not.toHaveProperty("openclaw");
585+
expect(manifest.dependencies).toMatchObject({
586+
"@openclaw/discord": "2026.5.5-beta.1",
587+
});
588+
const lockfile = JSON.parse(
589+
fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"),
590+
) as {
591+
packages?: Record<string, unknown>;
592+
dependencies?: Record<string, unknown>;
593+
};
594+
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
595+
expect(lockfile.dependencies?.openclaw).toBeUndefined();
596+
});
597+
510598
it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
511599
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
512600
const warnings: string[] = [];

src/plugins/install.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
1010
import {
1111
readManagedNpmRootInstalledDependency,
12+
repairManagedNpmRootOpenClawPeer,
1213
removeManagedNpmRootDependency,
1314
resolveManagedNpmRootDependencySpec,
1415
upsertManagedNpmRootDependency,
@@ -1335,6 +1336,12 @@ export async function installPluginFromNpmSpec(
13351336
}
13361337

13371338
logger.info?.(`Installing ${spec} into ${npmRoot}…`);
1339+
if (parsedSpec.name !== "openclaw") {
1340+
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ npmRoot });
1341+
if (repairedOpenClawPeer) {
1342+
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
1343+
}
1344+
}
13381345
await upsertManagedNpmRootDependency({
13391346
npmRoot,
13401347
packageName: parsedSpec.name,

0 commit comments

Comments
 (0)