From 636abde16c27377c490cb1087d214dafaf4e3586 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 21:33:21 +0800 Subject: [PATCH] fix: keep node file content byte-faithful in node_file_get Splitting the file with keepends=False and rejoining with newlines dropped the trailing newline of the last line (and every \r of CRLF files), so returned content was shorter than the file on disk and did not round-trip. Split with keepends=True and join the selected lines verbatim; pagination semantics are unchanged. --- .../gns3_copilot/gns3_client/api_handlers.py | 9 ++-- tests/agent/mcp/test_handlers.py | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index 8c5a794e6..994406132 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -474,23 +474,26 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d raw = raw[:MAX_NODE_FILE_BYTES] truncated = True - lines = raw.splitlines(keepends=False) + # keepends keeps the content byte-faithful: the trailing newline of the + # last line and any \r\n endings survive the round trip + lines = raw.splitlines(keepends=True) total_lines = len(lines) # Apply offset/limit selected = lines[offset: offset + limit] if offset < total_lines else [] has_more = (offset + limit) < total_lines or truncated + content = "".join(selected) return { "file_path": file_path, - "content": "\n".join(selected), + "content": content, "metadata": { "total_lines": total_lines, "total_bytes": total_bytes, "offset": offset, "limit": limit, "returned_lines": len(selected), - "returned_bytes": len("\n".join(selected).encode("utf-8")), + "returned_bytes": len(content.encode("utf-8")), "truncated": truncated or has_more, "has_more": has_more, }, diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 68ca430f9..c95846870 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -242,6 +242,49 @@ class TestNode: result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx) assert "command" in result + @staticmethod + def _file_conn(text): + conn = _mock_conn() + conn.http_call.return_value.text = text + return conn + + def test_file_get_keeps_trailing_newline(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2\n") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\nline2\n" + assert result["metadata"]["total_bytes"] == 12 + assert result["metadata"]["returned_bytes"] == 12 + assert result["metadata"]["has_more"] is False + + def test_file_get_keeps_crlf_endings(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\r\nline2\r\n") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\r\nline2\r\n" + assert result["metadata"]["returned_bytes"] == 14 + + def test_file_get_without_trailing_newline(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\nline2" + + def test_file_get_pagination(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2\nline3\n") + result = get_node_file_handler( + {"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg", "offset": 1, "limit": 1}, ctx + ) + assert result["content"] == "line2\n" + assert result["metadata"]["total_lines"] == 3 + assert result["metadata"]["returned_lines"] == 1 + assert result["metadata"]["has_more"] is True + # ── Link ────────────────────────────────────────────────────────────────