Decode git forge content responses for agents
All checks were successful
Container Image / build-and-push (push) Successful in 22s
All checks were successful
Container Image / build-and-push (push) Successful in 22s
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user