From f2bf925a387fc15988d9e3bde2a83f30438b7b36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 7 May 2026 11:54:48 +0100 Subject: [PATCH] fix: guard sandbox move cleanup identity --- .../sandbox/fs-bridge-mutation-helper.test.ts | 62 ++++++++++++++++++- .../sandbox/fs-bridge-mutation-helper.ts | 31 ++++++++-- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts index bf4d2fc8c14..eb309545e6b 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts @@ -66,7 +66,7 @@ const FORCED_EXDEV_MUTATION_PYTHON = SANDBOX_PINNED_MUTATION_PYTHON.replace( ); const FORCED_EXDEV_WITH_LATE_SOURCE_WRITE_MUTATION_PYTHON = FORCED_EXDEV_MUTATION_PYTHON.replace( - " remove_copied_entry(src_parent_fd, src_basename, ('dir', copied_children))", + " remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))", [ " late_parent_fd = open_dir(src_basename, dir_fd=src_parent_fd)", " late_fd = None", @@ -77,7 +77,28 @@ const FORCED_EXDEV_WITH_LATE_SOURCE_WRITE_MUTATION_PYTHON = FORCED_EXDEV_MUTATIO " if late_fd is not None:", " os.close(late_fd)", " os.close(late_parent_fd)", - " remove_copied_entry(src_parent_fd, src_basename, ('dir', copied_children))", + " remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))", + ].join("\n"), +); + +const FORCED_EXDEV_WITH_SOURCE_REPLACEMENT_MUTATION_PYTHON = FORCED_EXDEV_MUTATION_PYTHON.replace( + " remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))", + [ + " replacement_parent_fd = open_dir(src_basename, dir_fd=src_parent_fd)", + " replacement_dir_fd = None", + " replacement_fd = None", + " try:", + " replacement_dir_fd = open_dir('nested', dir_fd=replacement_parent_fd)", + " os.unlink('file.txt', dir_fd=replacement_dir_fd)", + " replacement_fd = os.open('file.txt', WRITE_FLAGS, 0o600, dir_fd=replacement_dir_fd)", + " os.write(replacement_fd, b'replacement')", + " finally:", + " if replacement_fd is not None:", + " os.close(replacement_fd)", + " if replacement_dir_fd is not None:", + " os.close(replacement_dir_fd)", + " os.close(replacement_parent_fd)", + " remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))", ].join("\n"), ); @@ -410,7 +431,42 @@ describe("sandbox pinned mutation helper", () => { await expect(fs.readFile(path.join(sourceRoot, "dir", "late.txt"), "utf8")).resolves.toBe( "late", ); - await expect(fs.stat(path.join(sourceRoot, "dir", "nested"))).rejects.toThrow(); + await expect( + fs.readFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "utf8"), + ).resolves.toBe("payload"); + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "preserves source entries replaced after the directory rename fallback copy phase", + async () => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { + const sourceRoot = path.join(root, "source"); + const destRoot = path.join(root, "dest"); + await fs.mkdir(path.join(sourceRoot, "dir", "nested"), { recursive: true }); + await fs.mkdir(destRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "payload", "utf8"); + + const result = runMutationWithSource(FORCED_EXDEV_WITH_SOURCE_REPLACEMENT_MUTATION_PYTHON, [ + "rename", + sourceRoot, + "", + "dir", + destRoot, + "", + "moved", + "1", + ]); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/source changed during move fallback cleanup/i); + await expect( + fs.readFile(path.join(destRoot, "moved", "nested", "file.txt"), "utf8"), + ).resolves.toBe("payload"); + await expect( + fs.readFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "utf8"), + ).resolves.toBe("replacement"); }); }, ); diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.ts b/src/agents/sandbox/fs-bridge-mutation-helper.ts index 2b07833eb7c..be0f4729509 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.ts @@ -158,6 +158,22 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [ " os.close(dir_fd)", " os.rmdir(basename, dir_fd=parent_fd)", "", + "def entry_identity(entry_stat):", + " return (", + " entry_stat.st_dev,", + " entry_stat.st_ino,", + " entry_stat.st_mode,", + " entry_stat.st_size,", + " getattr(entry_stat, 'st_mtime_ns', int(entry_stat.st_mtime * 1000000000)),", + " getattr(entry_stat, 'st_ctime_ns', int(entry_stat.st_ctime * 1000000000)),", + " )", + "", + "def same_identity(expected, entry_stat):", + " return expected == entry_identity(entry_stat)", + "", + "def source_changed_error(basename):", + " return OSError(getattr(errno, 'ESTALE', errno.EIO), 'source changed during move fallback cleanup', basename)", + "", "def copy_entry(src_parent_fd, src_basename, dst_parent_fd, dst_basename):", " src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)", " if stat.S_ISDIR(src_stat.st_mode) and not stat.S_ISLNK(src_stat.st_mode):", @@ -167,6 +183,7 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [ " dst_dir_fd = None", " try:", " src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)", + " src_stat = os.fstat(src_dir_fd)", " dst_dir_fd = open_dir(dst_basename, dir_fd=dst_parent_fd)", " for child in os.listdir(src_dir_fd):", " copied_children.append((child, copy_entry(src_dir_fd, child, dst_dir_fd, child)))", @@ -184,11 +201,11 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [ " os.close(src_dir_fd)", " if dst_dir_fd is not None:", " os.close(dst_dir_fd)", - " return ('dir', copied_children)", + " return ('dir', entry_identity(src_stat), copied_children)", " if stat.S_ISLNK(src_stat.st_mode):", " link_target = os.readlink(src_basename, dir_fd=src_parent_fd)", " os.symlink(link_target, dst_basename, dir_fd=dst_parent_fd)", - " return ('leaf', None)", + " return ('leaf', entry_identity(src_stat), None)", " src_fd = os.open(src_basename, READ_FLAGS, dir_fd=src_parent_fd)", " dst_fd = None", " try:", @@ -212,10 +229,13 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [ " if dst_fd is not None:", " os.close(dst_fd)", " os.close(src_fd)", - " return ('leaf', None)", + " return ('leaf', entry_identity(src_file_stat), None)", "", "def remove_copied_entry(parent_fd, basename, manifest):", - " kind, children = manifest", + " kind, expected_identity, children = manifest", + " current_stat = os.lstat(basename, dir_fd=parent_fd)", + " if not same_identity(expected_identity, current_stat):", + " raise source_changed_error(basename)", " if kind != 'dir':", " os.unlink(basename, dir_fd=parent_fd)", " return", @@ -245,6 +265,7 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [ " try:", " temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)", " src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)", + " src_stat = os.fstat(src_dir_fd)", " for child in os.listdir(src_dir_fd):", " copied_children.append((child, copy_entry(src_dir_fd, child, temp_dir_fd, child)))", " os.close(src_dir_fd)", @@ -262,7 +283,7 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [ " except FileNotFoundError:", " pass", " raise", - " remove_copied_entry(src_parent_fd, src_basename, ('dir', copied_children))", + " remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))", " os.fsync(dst_parent_fd)", " os.fsync(src_parent_fd)", " return",