diff --git a/workspace/a2a_tools.py b/workspace/a2a_tools.py index 6cce6d62..85cc5860 100644 --- a/workspace/a2a_tools.py +++ b/workspace/a2a_tools.py @@ -188,14 +188,34 @@ async def tool_delegate_task(workspace_id: str, task: str) -> str: if not workspace_id or not task: return "Error: workspace_id and task are required" - # Discover the target + # Discover the target. We still call discover_peer because it + # enforces access control + tells us whether the peer is online, + # but we DO NOT use the peer's reported URL for routing — see below. peer = await discover_peer(workspace_id) if not peer: return f"Error: workspace {workspace_id} not found or not accessible (check access control)" - target_url = peer.get("url", "") - if not target_url: - return f"Error: workspace {workspace_id} has no URL (may be offline)" + if (peer.get("status") or "").lower() == "offline": + return f"Error: workspace {workspace_id} is offline" + + # Route through the platform's A2A proxy instead of POSTing + # directly to peer["url"]. The peer's URL is whatever it last + # registered — for in-container peers that's a Docker-internal + # hostname like ``http://ws-X-Y:8000`` which only resolves inside + # the platform's container DNS. External callers (the standalone + # molecule-mcp wrapper running on an operator's laptop) hit + # `[Errno 8] nodename nor servname` every time they try to reach + # an in-container peer that way. The platform's + # ``/workspaces/:peer-id/a2a`` proxy works for BOTH paths: it + # forwards over the Docker network for in-container peers and is + # the only path external runtimes can use, so unifying on it + # makes the universal-MCP path actually universal. In-container + # callers pay one extra HTTP hop on the same bridge — microseconds + # — in exchange for one consistent code path. Verified live on + # 2026-04-30 against workspace 8dad3e29 → 97ac32e9 (Claude Code + # Agent) which replied correctly through the proxy after failing + # via direct connect. + target_url = f"{PLATFORM_URL}/workspaces/{workspace_id}/a2a" # Report delegation start — include the task text for traceability peer_name = peer.get("name") or _peer_names.get(workspace_id) or workspace_id[:8] diff --git a/workspace/tests/test_a2a_tools_impl.py b/workspace/tests/test_a2a_tools_impl.py index 90d31560..6b17d9d3 100644 --- a/workspace/tests/test_a2a_tools_impl.py +++ b/workspace/tests/test_a2a_tools_impl.py @@ -230,13 +230,44 @@ class TestToolDelegateTask: result = await a2a_tools.tool_delegate_task("ws-missing", "task") assert "not found" in result or "Error" in result - async def test_peer_has_no_url_returns_error(self): + async def test_offline_peer_returns_error(self): + """A peer with status=offline short-circuits before we hit the proxy.""" import a2a_tools - with patch("a2a_tools.discover_peer", return_value={"id": "ws-1", "url": ""}): + with patch("a2a_tools.discover_peer", return_value={"id": "ws-1", "status": "offline"}): mc = _make_http_mock() with patch("a2a_tools.httpx.AsyncClient", return_value=mc): result = await a2a_tools.tool_delegate_task("ws-1", "task") - assert "no URL" in result or "Error" in result + assert "offline" in result.lower() + + async def test_routes_through_platform_proxy_not_peer_url(self): + """tool_delegate_task must POST to ${PLATFORM_URL}/workspaces/:peer-id/a2a, + NOT to peer["url"]. The peer's URL is a Docker-internal hostname for + in-container peers; external molecule-mcp callers cannot resolve it. + Routing through the platform proxy works for both.""" + import a2a_tools + from a2a_client import PLATFORM_URL + + peer = { + "id": "ws-target", + # Internal-only URL — must NOT be used. + "url": "http://ws-target-internal:8000", + "name": "Worker", + "status": "online", + } + captured = {} + async def fake_send(target_url, message): + captured["target_url"] = target_url + captured["message"] = message + return "ok" + + with patch("a2a_tools.discover_peer", return_value=peer), \ + patch("a2a_tools.send_a2a_message", side_effect=fake_send), \ + patch("a2a_tools.report_activity", new=AsyncMock()): + await a2a_tools.tool_delegate_task("ws-target", "do thing") + + assert captured["target_url"] == f"{PLATFORM_URL}/workspaces/ws-target/a2a" + # Sanity: definitely NOT the peer's reported URL + assert captured["target_url"] != peer["url"] async def test_success_returns_result_text(self): """Happy path: peer found with URL, A2A returns a result."""