fix(agents): preserve sandbox write file modes

This commit is contained in:
Peter Steinberger
2026-05-02 07:11:58 +01:00
parent 49e2992be5
commit 3c26e4dc04
3 changed files with 56 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan.
- Agents/models: keep legacy CLI runtime model refs such as `claude-cli/*` in the configured allowlist after canonical runtime migration, so cron `payload.model` overrides keep working. Fixes #75753. Thanks @RyanSandoval.
- Gateway/watch: keep colored subsystem log prefixes in the managed tmux pane even when the parent shell exports `NO_COLOR`, while preserving explicit `FORCE_COLOR=0` opt-out. Thanks @vincentkoc.
- Plugin SDK: re-export `isPrivateIpAddress` from `plugin-sdk/ssrf-runtime`, restoring source-checkout builds for SearXNG and Firecrawl private-network guards. Thanks @vincentkoc.

View File

@@ -80,6 +80,46 @@ describe("sandbox pinned mutation helper", () => {
});
});
it.runIf(process.platform !== "win32")(
"preserves existing target file mode while writing",
async () => {
await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => {
const workspace = path.join(root, "workspace");
const filePath = path.join(workspace, "note.txt");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(filePath, "before", "utf8");
await fs.chmod(filePath, 0o644);
const result = runMutation(["write", workspace, "", "note.txt", "0"], "after");
expect(result.status).toBe(0);
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("after");
const fileStat = await fs.stat(filePath);
expect(fileStat.mode & 0o777).toBe(0o644);
});
},
);
it.runIf(process.platform !== "win32")(
"keeps restrictive existing target file mode while writing",
async () => {
await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => {
const workspace = path.join(root, "workspace");
const filePath = path.join(workspace, "secret.txt");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(filePath, "before", "utf8");
await fs.chmod(filePath, 0o600);
const result = runMutation(["write", workspace, "", "secret.txt", "0"], "after");
expect(result.status).toBe(0);
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("after");
const fileStat = await fs.stat(filePath);
expect(fileStat.mode & 0o777).toBe(0o600);
});
},
);
it.runIf(process.platform !== "win32")(
"reads through a pinned directory fd and rejects hardlinked files",
async () => {

View File

@@ -89,7 +89,17 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
" continue",
" raise RuntimeError('failed to allocate sandbox temp directory')",
"",
"def existing_regular_file_mode(parent_fd, basename):",
" try:",
" target_stat = os.lstat(basename, dir_fd=parent_fd)",
" except FileNotFoundError:",
" return None",
" if stat.S_ISREG(target_stat.st_mode):",
" return stat.S_IMODE(target_stat.st_mode)",
" return None",
"",
"def write_atomic(parent_fd, basename, stdin_buffer):",
" target_mode = existing_regular_file_mode(parent_fd, basename)",
" temp_fd = None",
" temp_name = None",
" try:",
@@ -99,6 +109,11 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [
" if not chunk:",
" break",
" os.write(temp_fd, chunk)",
" if target_mode is not None:",
" try:",
" os.fchmod(temp_fd, target_mode)",
" except AttributeError:",
" pass",
" os.fsync(temp_fd)",
" os.close(temp_fd)",
" temp_fd = None",