diff --git a/tests/tools/test_file_write_safety.py b/tests/tools/test_file_write_safety.py index 9fecf8ece2..58441f8188 100644 --- a/tests/tools/test_file_write_safety.py +++ b/tests/tools/test_file_write_safety.py @@ -371,6 +371,26 @@ class TestBomHandling: assert b"print('world')" in raw + def test_v4a_update_keeps_terminal_escape_bytes_on_untouched_lines(self, ops, tmp_path: Path): + # read_file_raw feeds the V4A write-back; its leak cleanup must not eat the file's own + # OSC title escapes and BEL bytes on lines the patch never touched. + target = tmp_path / "prompt.sh" + original = (b'set_title() { printf "\x1b]0;%s\x07" "$1"; }\n' + b'beep() { printf "\x07"; }\n' + b'VERSION=1\n') + target.write_bytes(original) + patch = ( + "*** Begin Patch\n" + f"*** Update File: {target}\n" + "@@\n" + "-VERSION=1\n" + "+VERSION=2\n" + "*** End Patch" + ) + res = ops.patch_v4a(patch) + assert res.success, res.error + assert target.read_bytes() == original.replace(b"VERSION=1", b"VERSION=2") + class TestProtectedInstructionFiles: """Writes to agent-instruction files ALWAYS require approval. diff --git a/tools/file_operations.py b/tools/file_operations.py index 01d25f151b..ffd578fdfe 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -1085,7 +1085,8 @@ class ShellFileOperations(LintMixin, SearchMixin, FileOperations): return ReadResult(error=f"Failed to read file: {cat_result.stdout}") # Strip a leading BOM (a phantom U+FEFF defeats an exact first-line match); # write_file re-probes disk and restores it. - raw_content, _ = _strip_bom(_strip_terminal_fence_leaks(cat_result.stdout)) + # V4A writes this back, so only lines carrying a leaked fence are cleaned. + raw_content, _ = _strip_bom(_strip_terminal_fence_leaks(cat_result.stdout, fenced_lines_only=True)) return ReadResult(content=raw_content, file_size=file_size) def read_file_bytes(self, path: str, max_bytes: Optional[int] = None) -> ReadResult: diff --git a/tools/file_operations_common.py b/tools/file_operations_common.py index fa616482a0..324cdef2fa 100644 --- a/tools/file_operations_common.py +++ b/tools/file_operations_common.py @@ -204,13 +204,20 @@ def count_conflict_blocks(formatted_content: str) -> int: return min(opens, len(_CONFLICT_CLOSE.findall(formatted_content))) if opens else 0 -def _strip_terminal_fence_leaks(text: str) -> str: +def _strip_terminal_fence_leaks(text: str, *, fenced_lines_only: bool = False) -> str: """Strip leaked terminal fence wrappers (OSC sequences, fence markers) from - command output; drops lines that were nothing but wrapper.""" + command output; drops lines that were nothing but wrapper. + + ``fenced_lines_only`` is for file content that gets written back: a leak always carries the + fence marker, so a line without one is the file's own bytes (a prompt script's ``\x1b]0;`` + title escape, a BEL) and is kept verbatim.""" if not text: return text cleaned_lines: List[str] = [] for line in text.splitlines(keepends=True): + if fenced_lines_only and "__HERMES_FENCE_" not in line: + cleaned_lines.append(line) + continue had_terminal_wrapper = "__HERMES_FENCE_" in line or "\x1b]" in line cleaned = _FENCE_MARKER_RE.sub("", _OSC_SEQUENCE_RE.sub("", line)).replace("\x07", "") if had_terminal_wrapper and cleaned.strip("'\r\n\t ") == "":