fix(mcp): insert bare-filename rewrites literally (#5522)

_rewrite_unique_bare_filenames handed the correlated /mnt/user-data virtual
path to Pattern.subn as a replacement template. That path is built from the
file's relative path, and a backslash is an ordinary character in a POSIX
filename, so a file written literally as "screenshots\q3.png" -- the shape a
model produces by passing a Windows-style path to a stdio server on a POSIX
host -- turned \q into an unknown template escape. Pattern.subn compiles the
template eagerly, so re.error escaped _convert_call_tool_result and failed the
whole tool call even though the server had already written the file, and the
agent never saw the path.

When the backslash does start a known escape (\r, \t, \b ...), the bare-filename
pass substituted that byte into the returned text instead, so
"screenshots\raw.png" came back as a path with a raw CR in the middle of it.

Insert the correlated path through a callable replacement, matching what
_rewrite_local_paths_in_text already does, so it is never parsed as a template.
This commit is contained in:
哈基米 2026-09-18 08:03:37 +08:00 committed by GitHub
parent d811143b52
commit 796ca28f55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 46 additions and 1 deletions

View File

@ -277,7 +277,10 @@ def _rewrite_unique_bare_filenames(
# Do not rewrite inside longer paths/words. A final sentence period is
# allowed, but ".bak" or another path segment is not.
pattern = re.compile(rf"(?<![\w./-]){re.escape(name)}(?!(?:[\w/-]|\.[\w]))")
rewritten_text, count = pattern.subn(unique[name], rewritten)
# A callable replacement, not a template: the virtual path is built from
# the real file's relative path, where a backslash is an ordinary
# character, so it must never be read as a regex escape.
rewritten_text, count = pattern.subn(lambda _match: unique[name], rewritten)
if count:
logger.debug("MCP bare filename rewrite: %s -> %s", name, unique[name])
rewritten = rewritten_text

View File

@ -426,6 +426,48 @@ class TestRewriteLocalPathsInText:
assert result == text
@pytest.mark.skipif(os.name == "nt", reason="a literal backslash in a filename is POSIX-only")
class TestRewriteUniqueBareFilenames:
"""The correlated virtual path must be inserted verbatim, never as a template.
The replacement is built from the real file's relative path, where a
backslash is an ordinary character, so handing it to ``re.sub`` as a
template reads it as a regex escape instead.
"""
def test_backslash_in_replacement_is_inserted_literally(self, paths: Paths):
workspace = paths.sandbox_work_dir("t1", user_id="u1")
src = _workspace_file(paths, r"screenshots\raw.png")
text = r"Saved as screenshots\raw.png"
with _patch_paths(paths):
result = mcp_tools._rewrite_unique_bare_filenames(
text,
changed_files=[src],
thread_id="t1",
user_id="u1",
source_base_dir=workspace,
)
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/screenshots\\raw.png"
def test_unknown_regex_escape_in_replacement_does_not_raise(self, paths: Paths):
workspace = paths.sandbox_work_dir("t1", user_id="u1")
src = _workspace_file(paths, r"screenshots\q3.png")
text = r"Saved as screenshots\q3.png"
with _patch_paths(paths):
result = mcp_tools._rewrite_unique_bare_filenames(
text,
changed_files=[src],
thread_id="t1",
user_id="u1",
source_base_dir=workspace,
)
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/screenshots\\q3.png"
class TestWorkspaceSnapshots:
def test_changed_workspace_files_detects_created_and_modified_files(self, paths: Paths):
import time