























@@ -46,6 +46,10 @@ function normalizeComparablePath(filePath: string): string {
4646return path.join(comparableParent, basename);
4747}
484849+function createFsError(code: string, message = code): NodeJS.ErrnoException {
50+return Object.assign(new Error(message), { code });
51+}
52+4953async function rebindInstallBasePath(params: {
5054installBaseDir: string;
5155preservedDir: string;
@@ -207,6 +211,49 @@ describe("installPackageDir", () => {
207211await expect(fs.readdir(backupRoot)).resolves.toHaveLength(0);
208212});
209213214+it("publishes the staged install through the copy fallback when rename crosses devices", async () => {
215+await fixtureRootTracker.setup();
216+const fixtureRoot = await fixtureRootTracker.make("case");
217+const sourceDir = path.join(fixtureRoot, "source");
218+const installBaseDir = path.join(fixtureRoot, "plugins");
219+const targetDir = path.join(installBaseDir, "demo");
220+await fs.mkdir(sourceDir, { recursive: true });
221+await fs.writeFile(path.join(sourceDir, "marker.txt"), "new");
222+223+const realRename = fs.rename.bind(fs);
224+let exdevMoves = 0;
225+vi.spyOn(fs, "rename").mockImplementation(async (...args: Parameters<typeof fs.rename>) => {
226+const [from, to] = args;
227+const fromPath = String(from);
228+if (
229+exdevMoves === 0 &&
230+path.basename(fromPath).startsWith(".openclaw-install-stage-") &&
231+normalizeComparablePath(String(to)) === normalizeComparablePath(targetDir)
232+) {
233+exdevMoves += 1;
234+throw createFsError("EXDEV", "cross-device link not permitted");
235+}
236+return await realRename(...args);
237+});
238+239+const result = await installPackageDir({
240+ sourceDir,
241+ targetDir,
242+mode: "install",
243+timeoutMs: 1_000,
244+copyErrorPrefix: "failed to copy plugin",
245+hasDeps: false,
246+depsLogMessage: "Installing deps…",
247+});
248+249+expect(result).toEqual({ ok: true });
250+expect(exdevMoves).toBe(1);
251+await expect(fs.readFile(path.join(targetDir, "marker.txt"), "utf8")).resolves.toBe("new");
252+await expect(
253+listMatchingDirs(installBaseDir, ".openclaw-install-stage-"),
254+).resolves.toHaveLength(0);
255+});
256+210257it("aborts without outside writes when the install base is rebound before publish", async () => {
211258await fixtureRootTracker.setup();
212259const fixtureRoot = await fixtureRootTracker.make("case");
@@ -255,25 +302,36 @@ describe("installPackageDir", () => {
255302await createReboundInstallFixture({ fixtureRoot, withExistingInstall: true });
256303257304const warnings: string[] = [];
258-const result = await withInstallBaseReboundOnRealpathCall({
259- installBaseDir,
260-preservedDir: preservedInstallRoot,
261-outsideTarget: outsideInstallRoot,
262-rebindAtCall: 8,
263-run: async () =>
264-await installPackageDir({
265- sourceDir,
266- targetDir,
267-mode: "update",
268-timeoutMs: 1_000,
269-copyErrorPrefix: "failed to copy plugin",
270-hasDeps: false,
271-depsLogMessage: "Installing deps…",
272-logger: { warn: (message) => warnings.push(message) },
273-}),
305+const installBasePath = normalizeComparablePath(installBaseDir);
306+const realStat = fs.stat.bind(fs);
307+let installBaseStatCalls = 0;
308+vi.spyOn(fs, "stat").mockImplementation(async (...args: Parameters<typeof fs.stat>) => {
309+if (normalizeComparablePath(String(args[0])) === installBasePath) {
310+installBaseStatCalls += 1;
311+if (installBaseStatCalls === 3) {
312+await rebindInstallBasePath({
313+ installBaseDir,
314+preservedDir: preservedInstallRoot,
315+outsideTarget: outsideInstallRoot,
316+});
317+}
318+}
319+return await realStat(...args);
320+});
321+322+const result = await installPackageDir({
323+ sourceDir,
324+ targetDir,
325+mode: "update",
326+timeoutMs: 1_000,
327+copyErrorPrefix: "failed to copy plugin",
328+hasDeps: false,
329+depsLogMessage: "Installing deps…",
330+logger: { warn: (message) => warnings.push(message) },
274331});
275332276333expect(result).toEqual({ ok: true });
334+expect(installBaseStatCalls).toBe(3);
277335expect(warnings).toContain(
278336"Install base directory changed before backup cleanup; leaving backup in place.",
279337);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。