Decode git forge content responses for agents
All checks were successful
Container Image / build-and-push (push) Successful in 22s

This commit is contained in:
2026-07-08 13:45:17 +02:00
parent b67f208cc0
commit ec4ac6ef8e
2 changed files with 91 additions and 3 deletions

View File

@@ -1,3 +1,4 @@
import base64
import json import json
import os import os
import uuid import uuid
@@ -372,6 +373,61 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]:
return None 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( async def execute_api_call(
integration_id: str, integration_id: str,
method: str, method: str,
@@ -490,6 +546,11 @@ async def execute_api_call(
if "application/json" in content_type: if "application/json" in content_type:
try: try:
data = response.json() data = response.json()
data = _decode_repository_content_response(
data,
preset=preset,
path=path,
)
full = json.dumps(data, indent=2, ensure_ascii=False) full = json.dumps(data, indent=2, ensure_ascii=False)
if len(full) > 12000: if len(full) > 12000:
if isinstance(data, list): if isinstance(data, list):

View File

@@ -4,6 +4,7 @@ Covers:
(a) Large JSON list response -> sentinel appended, valid JSON returned (a) Large JSON list response -> sentinel appended, valid JSON returned
(b) Small response -> returned unchanged, no truncation (b) Small response -> returned unchanged, no truncation
""" """
import base64
import json import json
import sys import sys
import os import os
@@ -72,7 +73,7 @@ def _make_response(json_data, status=200):
return resp 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_resp = _make_response(json_data, status)
mock_client = AsyncMock() mock_client = AsyncMock()
@@ -81,13 +82,13 @@ async def _call(json_data, status=200):
mock_client.request = AsyncMock(return_value=mock_resp) mock_client.request = AsyncMock(return_value=mock_resp)
with ( 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), patch("httpx.AsyncClient", return_value=mock_client),
# api.example.com doesn't resolve; the SSRF guard would fail closed. # api.example.com doesn't resolve; the SSRF guard would fail closed.
# These tests are about truncation, so stub the guard open. # These tests are about truncation, so stub the guard open.
patch("src.url_safety.check_outbound_url", return_value=(True, "ok")), 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"): 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 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 @pytest.mark.asyncio
async def test_list_truncation_respects_limit_including_sentinel(): async def test_list_truncation_respects_limit_including_sentinel():
"""After list truncation the total serialized body must not exceed 12000 chars, """After list truncation the total serialized body must not exceed 12000 chars,