From d882608fa1decc6b3e715e8291074e9028cc0de0 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 20:38:54 +0200 Subject: [PATCH] fix(tools): keep literal escape sequences when editing a file `astrbot_file_edit_tool` ran `old` and `new` through `_decode_escaped_text` unconditionally, so a backslash-n in the arguments always became a real line break. The read, write and grep tools pass their strings through unchanged, so a file written by `astrbot_file_write_tool` can hold a literal "\n" that the edit tool could then never match: any Python or JavaScript source with "\n" in a string literal, a regex such as r"\number", or a Windows path such as C:\new\temp. Inserting such text was corrupted the same way. Try the arguments as they were given first and fall back to the decoded form only when that finds nothing to replace, so a model that escapes its arguments keeps working. --- astrbot/core/tools/computer_tools/fs.py | 47 +++++++----- tests/test_computer_fs_tools.py | 98 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 19 deletions(-) diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index 906a2f0d8a..14a232b271 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -611,8 +611,14 @@ async def call( ) if not normalized_path: raise ValueError("`path` must be a non-empty string.") - normalized_old = _decode_escaped_text(old) - normalized_new = _decode_escaped_text(new) + # The read, write and grep tools all pass their strings through + # unchanged, so a file may legitimately contain a literal "\n". + # Use the arguments as they were given and only fall back to + # decoding escape sequences when that finds nothing to replace. + attempts = [(old, new)] + decoded = (_decode_escaped_text(old), _decode_escaped_text(new)) + if decoded != (old, new): + attempts.append(decoded) sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, @@ -631,23 +637,26 @@ async def call( access="edit", ) try: - if file_descriptor is None: - result = await sb.fs.edit_file( - path=normalized_path, - old_string=normalized_old, - new_string=normalized_new, - replace_all=replace_all, - encoding="utf-8", - ) - else: - result = await cast(Any, sb.fs).edit_file( - path=normalized_path, - old_string=normalized_old, - new_string=normalized_new, - replace_all=replace_all, - encoding="utf-8", - file_descriptor=file_descriptor, - ) + for old_string, new_string in attempts: + if file_descriptor is None: + result = await sb.fs.edit_file( + path=normalized_path, + old_string=old_string, + new_string=new_string, + replace_all=replace_all, + encoding="utf-8", + ) + else: + result = await cast(Any, sb.fs).edit_file( + path=normalized_path, + old_string=old_string, + new_string=new_string, + replace_all=replace_all, + encoding="utf-8", + file_descriptor=file_descriptor, + ) + if result.get("success", False): + break finally: if file_descriptor is not None: os.close(file_descriptor) diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py index a2f26670d8..ecbc783d3b 100644 --- a/tests/test_computer_fs_tools.py +++ b/tests/test_computer_fs_tools.py @@ -1080,3 +1080,101 @@ async def test_file_read_tool_rejects_directory_with_clear_message( assert "is a directory, not a file" in result assert "my-directory" in result assert "'astrbot_execute_shell'" in result + + +@pytest.mark.asyncio +async def test_file_edit_matches_source_that_contains_a_literal_escape( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + """A backslash-n inside source code is content, not a line break.""" + workspace = _setup_local_fs_tools(monkeypatch, tmp_path) + source = 'def greet():\n print("hello\\n")\n' + write_result = await fs_tools.FileWriteTool().call( + _make_context(), + path="greet.py", + content=source, + ) + + edit_result = await fs_tools.FileEditTool().call( + _make_context(), + path="greet.py", + old='print("hello\\n")', + new='print("goodbye\\n")', + ) + + assert "File written successfully" in write_result + assert "Replaced 1 occurrence" in edit_result + assert (workspace / "greet.py").read_text(encoding="utf-8") == ( + 'def greet():\n print("goodbye\\n")\n' + ) + + +@pytest.mark.asyncio +async def test_file_edit_inserts_a_literal_escape_unchanged( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + """The replacement keeps the escape the model wrote when `old` matched as given.""" + workspace = _setup_local_fs_tools(monkeypatch, tmp_path) + (workspace / "paths.py").write_text('LOG = "PLACEHOLDER"\n', encoding="utf-8") + + result = await fs_tools.FileEditTool().call( + _make_context( + local_permissions={"admin": {"filesystem_scope": "host"}}, + ), + path=str(workspace / "paths.py"), + old="PLACEHOLDER", + new="C:\\new\\temp\\report.txt", + ) + + assert "Replaced 1 occurrence" in result + assert (workspace / "paths.py").read_text(encoding="utf-8") == ( + 'LOG = "C:\\new\\temp\\report.txt"\n' + ) + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name == "nt", reason="Restricted file access needs POSIX.") +async def test_restricted_file_edit_matches_a_literal_escape( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + """The descriptor-based edit path keeps the same matching rules.""" + workspace = _setup_local_fs_tools(monkeypatch, tmp_path) + (workspace / "rules.py").write_text( + 'PATTERN = r"\\number\\t(\\d+)"\n', + encoding="utf-8", + ) + + result = await fs_tools.FileEditTool().call( + _make_context(role="member"), + path="rules.py", + old='r"\\number\\t(\\d+)"', + new='r"\\nitem\\t(\\d+)"', + ) + + assert "Replaced 1 occurrence" in result + assert (workspace / "rules.py").read_text(encoding="utf-8") == ( + 'PATTERN = r"\\nitem\\t(\\d+)"\n' + ) + + +@pytest.mark.asyncio +async def test_file_edit_still_decodes_escapes_when_the_text_does_not_match( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + """A model that escapes its arguments keeps working: the decode is a fallback.""" + workspace = _setup_local_fs_tools(monkeypatch, tmp_path) + (workspace / "notes.txt").write_text("alpha\nbeta\n", encoding="utf-8") + + result = await fs_tools.FileEditTool().call( + _make_context(), + path="notes.txt", + old="alpha\\nbeta", + new="alpha\\ngamma", + ) + + assert "Replaced 1 occurrence" in result + assert (workspace / "notes.txt").read_text(encoding="utf-8") == "alpha\ngamma\n"