Skip to content

Commit f2bf925

Browse files
committed
fix: guard sandbox move cleanup identity
1 parent 530e4f9 commit f2bf925

2 files changed

Lines changed: 85 additions & 8 deletions

File tree

src/agents/sandbox/fs-bridge-mutation-helper.test.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const FORCED_EXDEV_MUTATION_PYTHON = SANDBOX_PINNED_MUTATION_PYTHON.replace(
6666
);
6767

6868
const FORCED_EXDEV_WITH_LATE_SOURCE_WRITE_MUTATION_PYTHON = FORCED_EXDEV_MUTATION_PYTHON.replace(
69-
" remove_copied_entry(src_parent_fd, src_basename, ('dir', copied_children))",
69+
" remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))",
7070
[
7171
" late_parent_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
7272
" late_fd = None",
@@ -77,7 +77,28 @@ const FORCED_EXDEV_WITH_LATE_SOURCE_WRITE_MUTATION_PYTHON = FORCED_EXDEV_MUTATIO
7777
" if late_fd is not None:",
7878
" os.close(late_fd)",
7979
" os.close(late_parent_fd)",
80-
" remove_copied_entry(src_parent_fd, src_basename, ('dir', copied_children))",
80+
" remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))",
81+
].join("\n"),
82+
);
83+
84+
const FORCED_EXDEV_WITH_SOURCE_REPLACEMENT_MUTATION_PYTHON = FORCED_EXDEV_MUTATION_PYTHON.replace(
85+
" remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))",
86+
[
87+
" replacement_parent_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
88+
" replacement_dir_fd = None",
89+
" replacement_fd = None",
90+
" try:",
91+
" replacement_dir_fd = open_dir('nested', dir_fd=replacement_parent_fd)",
92+
" os.unlink('file.txt', dir_fd=replacement_dir_fd)",
93+
" replacement_fd = os.open('file.txt', WRITE_FLAGS, 0o600, dir_fd=replacement_dir_fd)",
94+
" os.write(replacement_fd, b'replacement')",
95+
" finally:",
96+
" if replacement_fd is not None:",
97+
" os.close(replacement_fd)",
98+
" if replacement_dir_fd is not None:",
99+
" os.close(replacement_dir_fd)",
100+
" os.close(replacement_parent_fd)",
101+
" remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))",
81102
].join("\n"),
82103
);
83104

@@ -410,7 +431,42 @@ describe("sandbox pinned mutation helper", () => {
410431
await expect(fs.readFile(path.join(sourceRoot, "dir", "late.txt"), "utf8")).resolves.toBe(
411432
"late",
412433
);
413-
await expect(fs.stat(path.join(sourceRoot, "dir", "nested"))).rejects.toThrow();
434+
await expect(
435+
fs.readFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "utf8"),
436+
).resolves.toBe("payload");
437+
});
438+
},
439+
);
440+
441+
it.runIf(process.platform !== "win32")(
442+
"preserves source entries replaced after the directory rename fallback copy phase",
443+
async () => {
444+
await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => {
445+
const sourceRoot = path.join(root, "source");
446+
const destRoot = path.join(root, "dest");
447+
await fs.mkdir(path.join(sourceRoot, "dir", "nested"), { recursive: true });
448+
await fs.mkdir(destRoot, { recursive: true });
449+
await fs.writeFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "payload", "utf8");
450+
451+
const result = runMutationWithSource(FORCED_EXDEV_WITH_SOURCE_REPLACEMENT_MUTATION_PYTHON, [
452+
"rename",
453+
sourceRoot,
454+
"",
455+
"dir",
456+
destRoot,
457+
"",
458+
"moved",
459+
"1",
460+
]);
461+
462+
expect(result.status).not.toBe(0);
463+
expect(result.stderr).toMatch(/source changed during move fallback cleanup/i);
464+
await expect(
465+
fs.readFile(path.join(destRoot, "moved", "nested", "file.txt"), "utf8"),
466+
).resolves.toBe("payload");
467+
await expect(
468+
fs.readFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "utf8"),
469+
).resolves.toBe("replacement");
414470
});
415471
},
416472
);

src/agents/sandbox/fs-bridge-mutation-helper.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,22 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
158158
" os.close(dir_fd)",
159159
" os.rmdir(basename, dir_fd=parent_fd)",
160160
"",
161+
"def entry_identity(entry_stat):",
162+
" return (",
163+
" entry_stat.st_dev,",
164+
" entry_stat.st_ino,",
165+
" entry_stat.st_mode,",
166+
" entry_stat.st_size,",
167+
" getattr(entry_stat, 'st_mtime_ns', int(entry_stat.st_mtime * 1000000000)),",
168+
" getattr(entry_stat, 'st_ctime_ns', int(entry_stat.st_ctime * 1000000000)),",
169+
" )",
170+
"",
171+
"def same_identity(expected, entry_stat):",
172+
" return expected == entry_identity(entry_stat)",
173+
"",
174+
"def source_changed_error(basename):",
175+
" return OSError(getattr(errno, 'ESTALE', errno.EIO), 'source changed during move fallback cleanup', basename)",
176+
"",
161177
"def copy_entry(src_parent_fd, src_basename, dst_parent_fd, dst_basename):",
162178
" src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)",
163179
" 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 = [
167183
" dst_dir_fd = None",
168184
" try:",
169185
" src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
186+
" src_stat = os.fstat(src_dir_fd)",
170187
" dst_dir_fd = open_dir(dst_basename, dir_fd=dst_parent_fd)",
171188
" for child in os.listdir(src_dir_fd):",
172189
" copied_children.append((child, copy_entry(src_dir_fd, child, dst_dir_fd, child)))",
@@ -184,11 +201,11 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
184201
" os.close(src_dir_fd)",
185202
" if dst_dir_fd is not None:",
186203
" os.close(dst_dir_fd)",
187-
" return ('dir', copied_children)",
204+
" return ('dir', entry_identity(src_stat), copied_children)",
188205
" if stat.S_ISLNK(src_stat.st_mode):",
189206
" link_target = os.readlink(src_basename, dir_fd=src_parent_fd)",
190207
" os.symlink(link_target, dst_basename, dir_fd=dst_parent_fd)",
191-
" return ('leaf', None)",
208+
" return ('leaf', entry_identity(src_stat), None)",
192209
" src_fd = os.open(src_basename, READ_FLAGS, dir_fd=src_parent_fd)",
193210
" dst_fd = None",
194211
" try:",
@@ -212,10 +229,13 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
212229
" if dst_fd is not None:",
213230
" os.close(dst_fd)",
214231
" os.close(src_fd)",
215-
" return ('leaf', None)",
232+
" return ('leaf', entry_identity(src_file_stat), None)",
216233
"",
217234
"def remove_copied_entry(parent_fd, basename, manifest):",
218-
" kind, children = manifest",
235+
" kind, expected_identity, children = manifest",
236+
" current_stat = os.lstat(basename, dir_fd=parent_fd)",
237+
" if not same_identity(expected_identity, current_stat):",
238+
" raise source_changed_error(basename)",
219239
" if kind != 'dir':",
220240
" os.unlink(basename, dir_fd=parent_fd)",
221241
" return",
@@ -245,6 +265,7 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
245265
" try:",
246266
" temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)",
247267
" src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
268+
" src_stat = os.fstat(src_dir_fd)",
248269
" for child in os.listdir(src_dir_fd):",
249270
" copied_children.append((child, copy_entry(src_dir_fd, child, temp_dir_fd, child)))",
250271
" os.close(src_dir_fd)",
@@ -262,7 +283,7 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
262283
" except FileNotFoundError:",
263284
" pass",
264285
" raise",
265-
" remove_copied_entry(src_parent_fd, src_basename, ('dir', copied_children))",
286+
" remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))",
266287
" os.fsync(dst_parent_fd)",
267288
" os.fsync(src_parent_fd)",
268289
" return",

0 commit comments

Comments
 (0)