fix(plugins): repair stale managed openclaw peers

This commit is contained in:
Vincent Koc
2026-05-05 20:29:50 -07:00
parent ae9f779e5f
commit 97c68301d2
5 changed files with 251 additions and 0 deletions

View File

@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc.
- 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.
- 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.
- 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.
- 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.
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
readManagedNpmRootInstalledDependency,
resolveManagedNpmRootDependencySpec,
@@ -167,4 +168,80 @@ describe("managed npm root", () => {
},
});
});
it("repairs stale managed openclaw peer state without dropping plugin packages", async () => {
const npmRoot = await makeTempRoot();
await fs.mkdir(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
openclaw: "2026.5.4",
"@openclaw/discord": "2026.5.4",
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
openclaw: "2026.5.4",
"@openclaw/discord": "2026.5.4",
},
},
"node_modules/openclaw": {
version: "2026.5.4",
},
"node_modules/@openclaw/discord": {
version: "2026.5.4",
},
},
dependencies: {
openclaw: {
version: "2026.5.4",
},
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
`${JSON.stringify({ name: "openclaw", version: "2026.5.4" })}\n`,
);
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot })).resolves.toBe(true);
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(manifest.dependencies).toEqual({
"@openclaw/discord": "2026.5.4",
});
const lockfile = JSON.parse(
await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8"),
) as {
packages?: Record<string, { dependencies?: Record<string, string>; version?: string }>;
dependencies?: Record<string, unknown>;
};
expect(lockfile.packages?.[""]?.dependencies).toEqual({
"@openclaw/discord": "2026.5.4",
});
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
expect(lockfile.packages?.["node_modules/@openclaw/discord"]?.version).toBe("2026.5.4");
expect(lockfile.dependencies?.openclaw).toBeUndefined();
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
code: "ENOENT",
});
});
});

View File

@@ -15,6 +15,12 @@ export type ManagedNpmRootInstalledDependency = {
resolved?: string;
};
type ManagedNpmRootLockfile = {
packages?: Record<string, unknown>;
dependencies?: Record<string, unknown>;
[key: string]: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -75,6 +81,78 @@ export async function upsertManagedNpmRootDependency(params: {
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
export async function repairManagedNpmRootOpenClawPeer(params: {
npmRoot: string;
}): Promise<boolean> {
let changed = false;
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);
if ("openclaw" in dependencies) {
const { openclaw: _removed, ...nextDependencies } = dependencies;
await fs.writeFile(
manifestPath,
`${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`,
"utf8",
);
changed = true;
}
const lockPath = path.join(params.npmRoot, "package-lock.json");
try {
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
let lockChanged = false;
if (isRecord(parsed.packages)) {
const rootPackage = parsed.packages[""];
if (isRecord(rootPackage) && isRecord(rootPackage.dependencies)) {
const dependencies = { ...rootPackage.dependencies };
if ("openclaw" in dependencies) {
delete dependencies.openclaw;
parsed.packages[""] = { ...rootPackage, dependencies };
lockChanged = true;
}
}
if ("node_modules/openclaw" in parsed.packages) {
delete parsed.packages["node_modules/openclaw"];
lockChanged = true;
}
}
if (isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies) {
const dependencies = { ...parsed.dependencies };
delete dependencies.openclaw;
parsed.dependencies = dependencies;
lockChanged = true;
}
if (lockChanged) {
await fs.writeFile(lockPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
changed = true;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
const openclawPackageDirExists = await fs
.lstat(openclawPackageDir)
.then(() => true)
.catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
return false;
}
throw err;
});
if (openclawPackageDirExists) {
await fs.rm(openclawPackageDir, { recursive: true, force: true });
changed = true;
}
return changed;
}
export async function readManagedNpmRootInstalledDependency(params: {
npmRoot: string;
packageName: string;

View File

@@ -507,6 +507,94 @@ describe("installPluginFromNpmSpec", () => {
},
);
it("repairs stale managed openclaw root packages before npm plugin installs", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
fs.writeFileSync(
path.join(npmRoot, "package.json"),
JSON.stringify(
{
private: true,
dependencies: {
openclaw: "2026.5.4",
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
openclaw: "2026.5.4",
},
},
"node_modules/openclaw": {
version: "2026.5.4",
resolved: "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.4.tgz",
},
},
dependencies: {
openclaw: {
version: "2026.5.4",
},
},
},
null,
2,
)}\n`,
"utf-8",
);
fs.writeFileSync(
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
JSON.stringify({
name: "openclaw",
version: "2026.5.4",
}),
"utf-8",
);
mockNpmViewAndInstall({
spec: "@openclaw/discord@beta",
packageName: "@openclaw/discord",
version: "2026.5.5-beta.1",
pluginId: "discord",
npmRoot,
peerDependencies: { openclaw: ">=2026.5.5-beta.1" },
expectedDependencySpec: "2026.5.5-beta.1",
});
const result = await installPluginFromNpmSpec({
spec: "@openclaw/discord@beta",
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(true);
const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(manifest.dependencies).not.toHaveProperty("openclaw");
expect(manifest.dependencies).toMatchObject({
"@openclaw/discord": "2026.5.5-beta.1",
});
const lockfile = JSON.parse(
fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"),
) as {
packages?: Record<string, unknown>;
dependencies?: Record<string, unknown>;
};
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
expect(lockfile.dependencies?.openclaw).toBeUndefined();
});
it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const warnings: string[] = [];

View File

@@ -9,6 +9,7 @@ import {
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
import {
readManagedNpmRootInstalledDependency,
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
resolveManagedNpmRootDependencySpec,
upsertManagedNpmRootDependency,
@@ -1335,6 +1336,12 @@ export async function installPluginFromNpmSpec(
}
logger.info?.(`Installing ${spec} into ${npmRoot}`);
if (parsedSpec.name !== "openclaw") {
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ npmRoot });
if (repairedOpenClawPeer) {
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
}
}
await upsertManagedNpmRootDependency({
npmRoot,
packageName: parsedSpec.name,