fix(file-tools): make write_file/patch atomic (temp-file + rename) (#35252)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * fix(file-tools): make write_file/patch atomic (temp-file + rename) write_file streamed content straight into the target via `cat > path`, so a crash, SIGKILL, or truncated pipe mid-write left the file half-written and corrupt. patch_replace routes through write_file, so it shared the flaw. Now writes stream into a temp file in the SAME directory and `mv` it over the target — a real same-filesystem rename, which is atomic on POSIX and on every terminal backend (local/docker/ssh/modal). A failed write leaves the original byte-intact and leaks no temp file. The existing file's mode is preserved across the swap (stat + chmod, GNU/BSD), and content still rides stdin so there's no ARG_MAX limit. A trap cleans the temp on any error path. Tests: added TestAtomicWrite (real LocalEnvironment, no mocks) covering inode-change-on-overwrite, mode preservation, failed-write-leaves-original, no-temp-leak, special chars, and patch routing. Updated two mocks in test_file_operations.py that keyed on the literal `cat >` write command to key on the stdin_data behavioral signal instead. 200 file-tool tests green.
This commit is contained in:
@@ -726,6 +726,60 @@ class ShellFileOperations(FileOperations):
|
||||
# Use single quotes and escape any single quotes in the string
|
||||
return "'" + arg.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
def _atomic_write(self, path: str, content: str) -> "ExecuteResult":
|
||||
"""Write ``content`` to ``path`` atomically via temp-file + rename.
|
||||
|
||||
Streams ``content`` over stdin into a temp file in the SAME
|
||||
directory as ``path`` (so the final ``mv`` is a real rename on the
|
||||
same filesystem, not a non-atomic cross-device copy), preserves the
|
||||
existing file's mode if it exists, then renames over the target.
|
||||
On any failure the temp file is removed so we never leak a partial
|
||||
``.hermes-tmp`` file next to the user's data, and the original file
|
||||
is left untouched. Content rides stdin so there is no ARG_MAX limit.
|
||||
|
||||
Returns an :class:`ExecuteResult`; ``exit_code == 0`` means the file
|
||||
was swapped into place atomically. A non-zero exit means nothing was
|
||||
renamed and the original (if any) is intact.
|
||||
"""
|
||||
q_path = self._escape_shell_arg(path)
|
||||
parent = os.path.dirname(path) or "."
|
||||
q_parent = self._escape_shell_arg(parent)
|
||||
# template basename: hidden so it doesn't show up in casual `ls`,
|
||||
# carries a marker so an orphaned temp (only possible on a hard
|
||||
# crash *between* cat and mv) is identifiable.
|
||||
tmpl = self._escape_shell_arg(".hermes-tmp.XXXXXX")
|
||||
|
||||
# One shell script, fully quoted. Notes:
|
||||
# - `mktemp` lands the temp in the target's own dir (-p) so `mv` is
|
||||
# same-FS atomic; we fall back to a PID-stamped name if the
|
||||
# backend lacks mktemp (rare; busybox/macOS/Linux all ship it).
|
||||
# - `chmod --reference` is GNU-only, so we read the octal mode with
|
||||
# `stat` (GNU `-c%a` or BSD `-f%Lp`) and `chmod` it explicitly;
|
||||
# silent best-effort — a perms-copy failure must not abort the
|
||||
# write, the file still lands with default umask perms.
|
||||
# - `trap ... EXIT` guarantees the temp is removed on every error
|
||||
# path (cat failure, mv failure, signal) but NOT after a
|
||||
# successful mv (the temp no longer exists by then).
|
||||
# - we `cat >` the temp, then `mv -f` it over the target.
|
||||
script = (
|
||||
"set -e; "
|
||||
f"d={q_parent}; t={q_path}; "
|
||||
'tmp="$(mktemp -p "$d" ' + tmpl + ' 2>/dev/null '
|
||||
'|| mktemp "$d/.hermes-tmp.$$.XXXXXX" 2>/dev/null '
|
||||
'|| { tmp="$d/.hermes-tmp.$$"; : > "$tmp" && echo "$tmp"; })"; '
|
||||
'[ -n "$tmp" ] || { echo "atomic write: could not create temp file" >&2; exit 1; }; '
|
||||
"trap 'rm -f \"$tmp\"' EXIT; "
|
||||
# preserve mode of an existing target (best-effort, never fatal)
|
||||
'if [ -e "$t" ]; then '
|
||||
'm="$(stat -c%a "$t" 2>/dev/null || stat -f%Lp "$t" 2>/dev/null || true)"; '
|
||||
'[ -n "$m" ] && chmod "$m" "$tmp" 2>/dev/null || true; '
|
||||
"fi; "
|
||||
'cat > "$tmp"; '
|
||||
'mv -f "$tmp" "$t"; '
|
||||
"trap - EXIT"
|
||||
)
|
||||
return self._exec(script, stdin_data=content)
|
||||
|
||||
def _detect_file_line_ending(self, path: str, pre_content: Optional[str] = None) -> Optional[str]:
|
||||
"""Detect the dominant line ending of a file on disk.
|
||||
|
||||
@@ -1053,10 +1107,22 @@ class ShellFileOperations(FileOperations):
|
||||
if mkdir_result.exit_code == 0:
|
||||
dirs_created = True
|
||||
|
||||
# Write via stdin pipe — content bypasses shell arg parsing entirely,
|
||||
# so there's no ARG_MAX limit regardless of file size.
|
||||
write_cmd = f"cat > {self._escape_shell_arg(path)}"
|
||||
write_result = self._exec(write_cmd, stdin_data=content)
|
||||
# Write atomically: stream into a temp file in the SAME directory,
|
||||
# then ``mv`` it over the target. The rename is atomic on POSIX
|
||||
# (and on every backend FS we run on), so a crash / power loss /
|
||||
# truncated pipe mid-write leaves the original file intact instead
|
||||
# of a half-written corrupt file. Same-directory is load-bearing —
|
||||
# ``mv`` across filesystems degrades to copy+unlink, which is NOT
|
||||
# atomic; keeping the temp beside the target guarantees a real
|
||||
# rename. Content still rides stdin so there's no ARG_MAX limit.
|
||||
#
|
||||
# The temp file is created with ``mktemp`` (collision-safe) when the
|
||||
# backend has it, falling back to a PID-stamped name otherwise. We
|
||||
# then chmod the temp to match the existing file's mode (if any) so
|
||||
# the atomic swap doesn't silently widen or narrow permissions, and
|
||||
# clean the temp up on any failure so we never leak a ``.hermes-tmp``
|
||||
# turd next to the user's file.
|
||||
write_result = self._atomic_write(path, content)
|
||||
|
||||
if write_result.exit_code != 0:
|
||||
return WriteResult(error=f"Failed to write file: {write_result.stdout}")
|
||||
|
||||
Reference in New Issue
Block a user