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 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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user