From ec4ac6ef8e35d7c7469051e234fe002693c1e625 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Wed, 8 Jul 2026 13:45:17 +0200 Subject: [PATCH] Decode git forge content responses for agents --- src/integrations.py | 61 +++++++++++++++++++ .../test_integrations_api_call_truncation.py | 33 +++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/integrations.py b/src/integrations.py index 92b628f..a94e09b 100644 --- a/src/integrations.py +++ b/src/integrations.py @@ -1,3 +1,4 @@ +import base64 import json import os import uuid @@ -372,6 +373,61 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]: return None +def _decode_repository_content_response( + data: Any, + *, + preset: str, + path: str, +) -> Any: + """Make git forge contents responses usable for agents. + + Gitea/GitHub contents endpoints return file bytes as a Base64 string. + Sending that raw blob to the model wastes context and encourages the agent + to manually decode it in chat. Decode text files here and omit the raw blob + from the tool output. + """ + + if not isinstance(data, dict): + return data + + if preset not in {"gitea", "github"} and "/contents/" not in path: + return data + + if data.get("encoding") != "base64" or not isinstance(data.get("content"), str): + return data + + normalized = dict(data) + raw_content = normalized.pop("content", "") + try: + decoded_bytes = base64.b64decode(raw_content, validate=False) + except Exception: + normalized["content_omitted"] = True + normalized["content_error"] = "Base64 content could not be decoded" + return normalized + + normalized["content_omitted"] = True + normalized["content_original_encoding"] = "base64" + normalized["decoded_size_bytes"] = len(decoded_bytes) + + try: + decoded_text = decoded_bytes.decode("utf-8") + except UnicodeDecodeError: + normalized["decoded_content"] = None + normalized["content_error"] = "Decoded content is not valid UTF-8" + return normalized + + decoded_limit = 8000 + if len(decoded_text) > decoded_limit: + normalized["decoded_content"] = decoded_text[:decoded_limit] + normalized["decoded_truncated"] = True + normalized["decoded_total_chars"] = len(decoded_text) + else: + normalized["decoded_content"] = decoded_text + normalized["decoded_truncated"] = False + + return normalized + + async def execute_api_call( integration_id: str, method: str, @@ -490,6 +546,11 @@ async def execute_api_call( if "application/json" in content_type: try: data = response.json() + data = _decode_repository_content_response( + data, + preset=preset, + path=path, + ) full = json.dumps(data, indent=2, ensure_ascii=False) if len(full) > 12000: if isinstance(data, list): diff --git a/tests/test_integrations_api_call_truncation.py b/tests/test_integrations_api_call_truncation.py index bf1ec7d..5cd51d5 100644 --- a/tests/test_integrations_api_call_truncation.py +++ b/tests/test_integrations_api_call_truncation.py @@ -4,6 +4,7 @@ Covers: (a) Large JSON list response -> sentinel appended, valid JSON returned (b) Small response -> returned unchanged, no truncation """ +import base64 import json import sys import os @@ -72,7 +73,7 @@ def _make_response(json_data, status=200): return resp -async def _call(json_data, status=200): +async def _call(json_data, status=200, *, integration=None, path="/items"): mock_resp = _make_response(json_data, status) mock_client = AsyncMock() @@ -81,13 +82,13 @@ async def _call(json_data, status=200): mock_client.request = AsyncMock(return_value=mock_resp) with ( - patch.object(integrations, "_find_integration", return_value=DUMMY_INTEGRATION), + patch.object(integrations, "_find_integration", return_value=integration or DUMMY_INTEGRATION), patch("httpx.AsyncClient", return_value=mock_client), # api.example.com doesn't resolve; the SSRF guard would fail closed. # These tests are about truncation, so stub the guard open. patch("src.url_safety.check_outbound_url", return_value=(True, "ok")), ): - return await integrations.execute_api_call("test_integ", "GET", "/items") + return await integrations.execute_api_call("test_integ", "GET", path) async def _call_with_integration(integration, path="/items"): @@ -230,6 +231,32 @@ async def test_small_json_dict_not_truncated(): assert "_truncated" not in parsed +@pytest.mark.asyncio +async def test_gitea_contents_response_decodes_base64_and_omits_raw_blob(): + text = 'APP_VERSION = "1.0.1"\n' + payload = { + "name": "constants.py", + "path": "src/constants.py", + "encoding": "base64", + "content": base64.b64encode(text.encode("utf-8")).decode("ascii"), + } + integration = {**DUMMY_INTEGRATION, "preset": "gitea"} + + result = await _call( + payload, + integration=integration, + path="/api/v1/repos/MrSphay/odysseus/contents/src/constants.py", + ) + + assert result.get("exit_code") == 0 + body = result["output"].split(chr(10), 1)[1] + parsed = json.loads(body) + assert parsed["decoded_content"] == text + assert parsed["decoded_truncated"] is False + assert parsed["content_omitted"] is True + assert "content" not in parsed + + @pytest.mark.asyncio async def test_list_truncation_respects_limit_including_sentinel(): """After list truncation the total serialized body must not exceed 12000 chars,