fix(plugins): harden install ledger path handling

This commit is contained in:
Vincent Koc
2026-04-25 11:46:06 -07:00
parent dc19069d71
commit 9bd348fdec
6 changed files with 101 additions and 10 deletions

View File

@@ -29,8 +29,16 @@ cat > \"\$HOME/.openclaw/extensions/lossless-claw/package.json\" <<'JSON'
JSON
cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON'
{
\"plugins\": {
\"installs\": {
\"plugins\": {}
}
JSON
mkdir -p \"\$HOME/.openclaw/plugins\"
cat > \"\$HOME/.openclaw/plugins/installs.json\" <<'JSON'
{
\"version\": 1,
\"warning\": \"DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.\",
\"updatedAtMs\": 1777118400000,
\"records\": {
\"lossless-claw\": {
\"source\": \"npm\",
\"spec\": \"@example/lossless-claw@0.9.0\",
@@ -41,7 +49,6 @@ cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON'
\"integrity\": \"sha512-same\",
\"shasum\": \"same\"
}
}
}
}
JSON

View File

@@ -74,16 +74,30 @@ const config = fs.existsSync(configPath)
const plugins = (config.plugins ??= {});
const entries = (plugins.entries ??= {});
entries[pluginId] = { ...(entries[pluginId] ?? {}), enabled };
const installs = (plugins.installs ??= {});
installs[pluginId] = {
...(installs[pluginId] ?? {}),
delete plugins.installs;
plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).sort();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
const ledgerPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json");
const ledger = fs.existsSync(ledgerPath)
? JSON.parse(fs.readFileSync(ledgerPath, "utf8"))
: {
version: 1,
warning:
"DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.",
records: {},
};
ledger.updatedAtMs = Date.now();
ledger.records ??= {};
ledger.records[pluginId] = {
...(ledger.records[pluginId] ?? {}),
source: "path",
installPath: pluginRoot,
sourcePath: pluginRoot,
};
plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).sort();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8");
NODE
}

View File

@@ -90,6 +90,25 @@ describe("json file helpers", () => {
});
});
it("replaces symlink targets instead of writing through them on Windows rename fallback", async () => {
await withTempDir({ prefix: "openclaw-json-files-" }, async (base) => {
const filePath = path.join(base, "state.json");
const outsidePath = path.join(base, "outside.json");
await fs.writeFile(outsidePath, "outside", "utf8");
await fs.symlink(outsidePath, filePath);
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
const renameError = Object.assign(new Error("EPERM"), { code: "EPERM" });
vi.spyOn(fs, "rename").mockRejectedValueOnce(renameError);
await writeTextAtomic(filePath, "new");
await expect(fs.lstat(filePath)).resolves.toSatisfy((stat) => !stat.isSymbolicLink());
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("new");
await expect(fs.readFile(outsidePath, "utf8")).resolves.toBe("outside");
});
});
it.each([
{
name: "serializes async lock callers even across rejections",

View File

@@ -18,6 +18,13 @@ async function replaceFileWithWindowsFallback(tempPath: string, filePath: string
}
}
const existing = await fs.lstat(filePath).catch(() => null);
if (existing?.isSymbolicLink()) {
await fs.rm(filePath, { force: true });
await fs.rename(tempPath, filePath);
return;
}
await fs.copyFile(tempPath, filePath);
try {
await fs.chmod(filePath, mode);

View File

@@ -273,6 +273,7 @@ describe("updateNpmInstalledPlugins", () => {
});
afterEach(() => {
vi.unstubAllEnvs();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -410,6 +411,47 @@ describe("updateNpmInstalledPlugins", () => {
]);
});
it("expands home-relative install paths before checking installed npm versions", async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-home-"));
tempDirs.push(home);
const installPath = path.join(home, ".openclaw", "extensions", "lossless-claw");
fs.mkdirSync(installPath, { recursive: true });
fs.writeFileSync(
path.join(installPath, "package.json"),
JSON.stringify({ name: "@martian-engineering/lossless-claw", version: "0.9.0" }),
);
vi.stubEnv("HOME", home);
mockNpmViewMetadata({
name: "@martian-engineering/lossless-claw",
version: "0.9.0",
integrity: "sha512-same",
shasum: "same",
});
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
const result = await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "lossless-claw",
spec: "@martian-engineering/lossless-claw",
installPath: "~/.openclaw/extensions/lossless-claw",
resolvedName: "@martian-engineering/lossless-claw",
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
integrity: "sha512-same",
}),
pluginIds: ["lossless-claw"],
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.outcomes).toEqual([
expect.objectContaining({
pluginId: "lossless-claw",
status: "unchanged",
currentVersion: "0.9.0",
}),
]);
});
it("falls through to npm reinstall when the recorded integrity differs", async () => {
const installPath = createInstalledPackageDir({
name: "@martian-engineering/lossless-claw",

View File

@@ -523,7 +523,9 @@ export async function updateNpmInstalledPlugins(params: {
let installPath: string;
try {
installPath = record.installPath ?? resolvePluginInstallDir(pluginId);
installPath = resolveUserPath(
record.installPath?.trim() || resolvePluginInstallDir(pluginId),
);
} catch (err) {
outcomes.push({
pluginId,