diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1148e2302..ccd9a3ba663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts index fe634529750..a5320e89291 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts @@ -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 () => { diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.ts b/src/agents/sandbox/fs-bridge-mutation-helper.ts index 7fb39d894f7..1637ffa18aa 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.ts @@ -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",