diff --git a/scripts/e2e/plugin-update-unchanged-docker.sh b/scripts/e2e/plugin-update-unchanged-docker.sh index 5837784b7b6..dd586fee1c5 100755 --- a/scripts/e2e/plugin-update-unchanged-docker.sh +++ b/scripts/e2e/plugin-update-unchanged-docker.sh @@ -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 diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index b866a85bbbc..36792ac386f 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -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 } diff --git a/src/infra/json-files.test.ts b/src/infra/json-files.test.ts index c0e891a3b24..c1166ec5f49 100644 --- a/src/infra/json-files.test.ts +++ b/src/infra/json-files.test.ts @@ -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", diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index 137249ed1f0..b79cb1d845e 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -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); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index e7c00bf502c..1c665d5a37f 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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", diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 1b904210a1f..5602c488b00 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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,