Skip to content

Commit d29bd19

Browse files
Preserve root-managed OpenClaw runtime during plugin installs
1 parent 78eb92e commit d29bd19

3 files changed

Lines changed: 195 additions & 0 deletions

File tree

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,71 @@ describe("managed npm root", () => {
509509
}
510510
await expectPathMissing(path.join(npmRoot, "node_modules", ".package-lock.json"));
511511
});
512+
513+
it("does not repair the active OpenClaw host package in a root-managed install", async () => {
514+
const npmRoot = await makeTempRoot();
515+
const hostPackageRoot = path.join(npmRoot, "node_modules", "openclaw");
516+
await fs.mkdir(path.join(hostPackageRoot, "dist"), { recursive: true });
517+
await fs.writeFile(
518+
path.join(npmRoot, "package.json"),
519+
`${JSON.stringify(
520+
{
521+
private: true,
522+
dependencies: {
523+
openclaw: "2026.5.12-beta.6",
524+
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
525+
},
526+
},
527+
null,
528+
2,
529+
)}\n`,
530+
);
531+
await fs.writeFile(
532+
path.join(npmRoot, "package-lock.json"),
533+
`${JSON.stringify(
534+
{
535+
lockfileVersion: 3,
536+
packages: {
537+
"": {
538+
dependencies: {
539+
openclaw: "2026.5.12-beta.6",
540+
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
541+
},
542+
},
543+
"node_modules/openclaw": {
544+
version: "2026.5.12-beta.6",
545+
},
546+
},
547+
},
548+
null,
549+
2,
550+
)}\n`,
551+
);
552+
await fs.writeFile(
553+
path.join(hostPackageRoot, "package.json"),
554+
`${JSON.stringify({ name: "openclaw", version: "2026.5.12-beta.6" })}\n`,
555+
);
556+
557+
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
558+
await expect(
559+
repairManagedNpmRootOpenClawPeer({
560+
npmRoot,
561+
packageRoot: hostPackageRoot,
562+
runCommand,
563+
}),
564+
).resolves.toBe(false);
565+
566+
expect(runCommand).not.toHaveBeenCalled();
567+
await expect(
568+
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
569+
).resolves.toMatchObject({
570+
dependencies: {
571+
openclaw: "2026.5.12-beta.6",
572+
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
573+
},
574+
});
575+
await expect(
576+
fs.readFile(path.join(hostPackageRoot, "package.json"), "utf8"),
577+
).resolves.toContain("2026.5.12-beta.6");
578+
});
512579
});

src/infra/npm-managed-root.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,12 +578,22 @@ export async function syncManagedNpmRootPeerDependencies(params: {
578578

579579
export async function repairManagedNpmRootOpenClawPeer(params: {
580580
npmRoot: string;
581+
packageRoot?: string | null;
581582
timeoutMs?: number;
582583
logger?: ManagedNpmRootLogger;
583584
runCommand?: ManagedNpmRootRunCommand;
584585
}): Promise<boolean> {
585586
await fs.mkdir(params.npmRoot, { recursive: true });
586587

588+
if (
589+
await managedNpmRootOpenClawPackageIsActiveHost({
590+
npmRoot: params.npmRoot,
591+
packageRoot: params.packageRoot,
592+
})
593+
) {
594+
return false;
595+
}
596+
587597
const manifestPath = path.join(params.npmRoot, "package.json");
588598
const manifest = await readManagedNpmRootManifest(manifestPath);
589599
const dependencies = readDependencyRecord(manifest.dependencies);
@@ -640,6 +650,30 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
640650
return true;
641651
}
642652

653+
async function managedNpmRootOpenClawPackageIsActiveHost(params: {
654+
npmRoot: string;
655+
packageRoot?: string | null;
656+
}): Promise<boolean> {
657+
const packageRoot =
658+
params.packageRoot === undefined
659+
? resolveOpenClawPackageRootSync({
660+
argv1: process.argv[1],
661+
moduleUrl: import.meta.url,
662+
cwd: process.cwd(),
663+
})
664+
: params.packageRoot;
665+
if (!packageRoot) {
666+
return false;
667+
}
668+
669+
const managedOpenClawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
670+
const [hostPackageRoot, managedPackageRoot] = await Promise.all([
671+
realpathIfExists(packageRoot),
672+
realpathIfExists(managedOpenClawPackageDir),
673+
]);
674+
return hostPackageRoot !== null && hostPackageRoot === managedPackageRoot;
675+
}
676+
643677
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
644678
const lockPath = path.join(npmRoot, "package-lock.json");
645679
try {
@@ -666,6 +700,17 @@ async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<b
666700
}
667701
}
668702

703+
async function realpathIfExists(filePath: string): Promise<string | null> {
704+
try {
705+
return await fs.realpath(filePath);
706+
} catch (err) {
707+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
708+
return null;
709+
}
710+
throw err;
711+
}
712+
}
713+
669714
async function pathExists(filePath: string): Promise<boolean> {
670715
return await fs
671716
.lstat(filePath)

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,89 @@ describe("installPluginFromNpmSpec", () => {
949949
expect(lockfile.dependencies?.openclaw).toBeUndefined();
950950
});
951951

952+
it("preserves the active host openclaw runtime package during npm plugin installs", async () => {
953+
const stateDir = suiteTempRootTracker.makeTempDir();
954+
const npmRoot = path.join(stateDir, "npm");
955+
const hostPackageRoot = path.join(npmRoot, "node_modules", "openclaw");
956+
fs.mkdirSync(hostPackageRoot, { recursive: true });
957+
fs.writeFileSync(
958+
path.join(npmRoot, "package.json"),
959+
JSON.stringify(
960+
{
961+
private: true,
962+
dependencies: {
963+
openclaw: "2026.5.12-beta.6",
964+
},
965+
},
966+
null,
967+
2,
968+
),
969+
"utf-8",
970+
);
971+
fs.writeFileSync(
972+
path.join(npmRoot, "package-lock.json"),
973+
`${JSON.stringify(
974+
{
975+
lockfileVersion: 3,
976+
packages: {
977+
"": {
978+
dependencies: {
979+
openclaw: "2026.5.12-beta.6",
980+
},
981+
},
982+
"node_modules/openclaw": {
983+
version: "2026.5.12-beta.6",
984+
},
985+
},
986+
},
987+
null,
988+
2,
989+
)}\n`,
990+
"utf-8",
991+
);
992+
fs.writeFileSync(
993+
path.join(hostPackageRoot, "package.json"),
994+
JSON.stringify({
995+
name: "openclaw",
996+
version: "2026.5.12-beta.6",
997+
}),
998+
"utf-8",
999+
);
1000+
1001+
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostPackageRoot);
1002+
mockNpmViewAndInstall({
1003+
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
1004+
packageName: "@xdarkicex/openclaw-memory-libravdb",
1005+
version: "1.4.69",
1006+
pluginId: "libravdb-memory",
1007+
npmRoot,
1008+
expectedDependencySpec: "1.4.69",
1009+
});
1010+
1011+
const result = await installPluginFromNpmSpec({
1012+
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
1013+
npmDir: npmRoot,
1014+
logger: { info: () => {}, warn: () => {} },
1015+
});
1016+
1017+
expect(result.ok).toBe(true);
1018+
const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as {
1019+
dependencies?: Record<string, string>;
1020+
};
1021+
expect(manifest.dependencies?.openclaw).toBe("2026.5.12-beta.6");
1022+
expect(manifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBe("1.4.69");
1023+
expect(fs.existsSync(hostPackageRoot)).toBe(true);
1024+
expect(
1025+
runCommandWithTimeoutMock.mock.calls.some(
1026+
([argv]) =>
1027+
Array.isArray(argv) &&
1028+
argv[0] === "npm" &&
1029+
argv[1] === "uninstall" &&
1030+
argv.includes("openclaw"),
1031+
),
1032+
).toBe(false);
1033+
});
1034+
9521035
it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
9531036
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
9541037
const warnings: string[] = [];

0 commit comments

Comments
 (0)