diff --git a/src/tool_execution.py b/src/tool_execution.py index d23dcd631..5dcfad715 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -305,6 +305,14 @@ def _client_bridge(client_runtime_context: Optional[Dict]) -> Optional[Dict]: _ROUTED_BRIDGE_TOOLS = TUI_ROUTED_BRIDGE_TOOL_NAMES _BRIDGE_TOOL_TIMEOUT_S = 900.0 +# ``write_file`` transports UTF-8 source/text only. Keep this guard on the +# bridge route as well as the local WriteFileTool: native/Harbor runs use the +# bridge for workspace files, so otherwise a model can overwrite a verified +# PNG/PDF screenshot with a prose status message. +_TEXT_WRITE_BINARY_SUFFIXES = frozenset({ + ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".mp3", ".mp4", ".ogg", + ".pdf", ".png", ".wav", ".webm", ".webp", ".zip", +}) async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[str], client_runtime_context: Optional[Dict]): @@ -660,6 +668,15 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s "error": "write_file: path is required", "exit_code": 1, } + if os.path.splitext(path)[1].casefold() in _TEXT_WRITE_BINARY_SUFFIXES: + return f"write_file: {path[:80]}", { + "error": ( + f"write_file: refusing UTF-8 text for binary artifact path {path}. " + "Use Python or a format-specific creation tool, then inspect the result." + ), + "exit_code": 1, + "binary_artifact_preserved": True, + } return f"write_file: {path[:80]}", await _bridge_post( bridge, "/write", diff --git a/tests/test_execution_bridge.py b/tests/test_execution_bridge.py index 81c270b38..6e2614859 100644 --- a/tests/test_execution_bridge.py +++ b/tests/test_execution_bridge.py @@ -153,3 +153,32 @@ def test_scoped_execution_bridge_failure_logs_compact_warning(caplog) -> None: assert "Command timed out after 900 seconds" in result["error"] assert "Traceback" not in caplog.text assert "Scoped execution bridge test-environment failed for tool=host_shell" in caplog.text + + +def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch) -> None: + """The bridge path must preserve rendered media just like local writes.""" + called = False + + async def fake_post(*_args, **_kwargs): + nonlocal called + called = True + return {"output": "should not run", "exit_code": 0} + + monkeypatch.setattr(tool_execution, "_client_bridge", lambda _context: {"url": "http://bridge", "token": "x"}) + monkeypatch.setattr(tool_execution, "_bridge_post", fake_post) + + async def invoke(): + return await tool_execution._route_tool_via_bridge( + "write_file", + '{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}', + "run-1", + {"surface": "odysseus-tui"}, + ) + + description, result = asyncio.run(invoke()) + + assert description == "write_file: /tmp_workspace/results/screenshot.png" + assert result["exit_code"] == 1 + assert result["binary_artifact_preserved"] is True + assert "binary artifact path" in result["error"] + assert called is False