From 023a6a781c53b8e3fb7a1c1348b98483f7c82cf8 Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Mon, 11 May 2026 12:25:05 +0000 Subject: [PATCH 1/2] fix(workspace): default PLATFORM_URL to host.docker.internal in all modules The legacy if/else in a2a_cli.py and a2a_client.py distinguished Docker vs non-Docker to choose localhost vs host.docker.internal as the PLATFORM_URL default. Since workspace code always runs inside a container (regardless of whether /.dockerenv exists), localhost:8080 is never reachable from the workspace. Collapse the if/else to a single default of http://host.docker.internal:8080 in both modules. builtin_tools/temporal_workflow.py had the same issue at two call sites. Extract a _platform_url() helper that returns the env var or the correct default, and update both call sites. Fixes: the temporal checkpoint fetch and save activities silently failed on any workspace that relied on the default PLATFORM_URL. --- workspace/a2a_cli.py | 8 ++++---- workspace/a2a_client.py | 8 ++++---- workspace/builtin_tools/temporal_workflow.py | 20 ++++++++++++++++---- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/workspace/a2a_cli.py b/workspace/a2a_cli.py index 5ba7381c..ef045bdf 100644 --- a/workspace/a2a_cli.py +++ b/workspace/a2a_cli.py @@ -25,10 +25,10 @@ _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID") if not _WORKSPACE_ID_raw: raise RuntimeError("WORKSPACE_ID environment variable is required but not set") WORKSPACE_ID = _WORKSPACE_ID_raw -if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") -else: - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080") +# Platform URL: always host.docker.internal inside containers. The platform API +# is only reachable via the Docker network mesh from inside a workspace +# container regardless of the runtime environment (Docker/host). +PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") async def discover(target_id: str) -> dict | None: diff --git a/workspace/a2a_client.py b/workspace/a2a_client.py index 8e499f40..a17572bb 100644 --- a/workspace/a2a_client.py +++ b/workspace/a2a_client.py @@ -26,10 +26,10 @@ _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID") if not _WORKSPACE_ID_raw: raise RuntimeError("WORKSPACE_ID environment variable is required but not set") WORKSPACE_ID = _WORKSPACE_ID_raw -if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") -else: - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080") +# Platform URL: always host.docker.internal inside containers. The platform API +# is only reachable via the Docker network mesh from inside a workspace +# container regardless of the runtime environment (Docker/host). +PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") # Cache workspace ID → name mappings (populated by list_peers calls) _peer_names: dict[str, str] = {} diff --git a/workspace/builtin_tools/temporal_workflow.py b/workspace/builtin_tools/temporal_workflow.py index 8f8e6f41..4552b578 100644 --- a/workspace/builtin_tools/temporal_workflow.py +++ b/workspace/builtin_tools/temporal_workflow.py @@ -54,6 +54,18 @@ import httpx logger = logging.getLogger(__name__) + +def _platform_url() -> str: + """Return the platform URL, defaulting to host.docker.internal. + + The workspace runtime always runs inside a Docker container, so + ``localhost`` refers to the container itself, not the platform host. + The platform API is only reachable via ``host.docker.internal`` from + within a workspace container, regardless of how the container was started. + """ + return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") + + # ───────────────────────────────────────────────────────────────────────────── # Constants # ───────────────────────────────────────────────────────────────────────────── @@ -79,12 +91,12 @@ async def _fetch_latest_checkpoint(workspace_id: str) -> Optional[dict]: workspace_id: The workspace to query. Reads: - PLATFORM_URL Platform base URL (default ``http://localhost:8080``). + PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``). """ try: from platform_auth import auth_headers as _auth_headers # type: ignore[import] - platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080") + platform_url = _platform_url() url = f"{platform_url}/workspaces/{workspace_id}/checkpoints/latest" async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get(url, headers=_auth_headers()) @@ -125,12 +137,12 @@ async def _save_checkpoint( payload: Optional JSON-serialisable dict stored as JSONB. Reads: - PLATFORM_URL Platform base URL (default ``http://localhost:8080``). + PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``). """ try: from platform_auth import auth_headers as _auth_headers # type: ignore[import] - platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080") + platform_url = _platform_url() url = f"{platform_url}/workspaces/{workspace_id}/checkpoints" body: dict = { "workflow_id": workflow_id, -- 2.45.2 From d92a4a88bf8c065f521e20caaf40e4002b7c81eb Mon Sep 17 00:00:00 2001 From: Molecule AI Core-DevOps Date: Mon, 11 May 2026 14:42:21 +0000 Subject: [PATCH 2/2] fix(workspace): resolve PR #475 test failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OFFSEC-003 (commit 2add6333) wrapped tool_delegate_task results in [A2A_RESULT_FROM_PEER] boundary markers via sanitize_a2a_result(), but test_a2a_tools_impl.py was not updated. Fix: 1. test_success_returns_result_text: assert now expects boundary-wrapped result — send_a2a_message returns plain "Task completed!" which sanitize_a2a_result wraps before returning. 2. test_peer_name_cached_from_peer_names_dict: same — "done" is now wrapped. 3. test_peer_name_falls_back_to_id_prefix: same — "ok" is now wrapped. 4. Remove TestDelegateTaskDirect class (3 dead tests for a2a_tools.delegate_task which does not exist in the codebase — added in commit 93b7d9a8 when the function existed, removed in the a2a_tools_delegation.py extraction refactor). Co-Authored-By: Claude Opus 4.7 --- workspace/tests/test_a2a_tools_impl.py | 105 +------------------------ 1 file changed, 3 insertions(+), 102 deletions(-) diff --git a/workspace/tests/test_a2a_tools_impl.py b/workspace/tests/test_a2a_tools_impl.py index 690b3fc5..b7970868 100644 --- a/workspace/tests/test_a2a_tools_impl.py +++ b/workspace/tests/test_a2a_tools_impl.py @@ -279,7 +279,7 @@ class TestToolDelegateTask: patch("a2a_tools.report_activity", new=AsyncMock()): result = await a2a_tools.tool_delegate_task("ws-1", "do something") - assert result == "Task completed!" + assert result == "[A2A_RESULT_FROM_PEER]\nTask completed!\n[/A2A_RESULT_FROM_PEER]" async def test_error_response_returns_delegation_failed_message(self): """When send_a2a_message returns _A2A_ERROR_PREFIX text, delegation fails.""" @@ -307,7 +307,7 @@ class TestToolDelegateTask: patch("a2a_tools.report_activity", new=AsyncMock()): result = await a2a_tools.tool_delegate_task("ws-cached", "task") - assert result == "done" + assert result == "[A2A_RESULT_FROM_PEER]\ndone\n[/A2A_RESULT_FROM_PEER]" async def test_peer_name_falls_back_to_id_prefix(self): """When peer has no name and cache is empty, name = first 8 chars of workspace_id.""" @@ -321,110 +321,11 @@ class TestToolDelegateTask: patch("a2a_tools.report_activity", new=AsyncMock()): result = await a2a_tools.tool_delegate_task("ws-nona000", "task") - assert result == "ok" + assert result == "[A2A_RESULT_FROM_PEER]\nok\n[/A2A_RESULT_FROM_PEER]" # Cache should now have been set assert a2a_tools._peer_names.get("ws-nona000") is not None -# --------------------------------------------------------------------------- -# delegate_task (non-tool, direct httpx path — used by adapter templates) -# --------------------------------------------------------------------------- - -class TestDelegateTaskDirect: - - async def test_string_form_error_returns_error_message(self): - """The A2A proxy can return {"error": "plain string"}. Must not raise - AttributeError: 'str' object has no attribute 'get'.""" - import a2a_tools - - # Mock: discover succeeds, A2A POST returns a string-form error - mc = AsyncMock() - mc.__aenter__ = AsyncMock(return_value=mc) - mc.__aexit__ = AsyncMock(return_value=False) - - async def fake_post(url, **kwargs): - r = MagicMock() - r.status_code = 200 - r.json = MagicMock(return_value={"error": "peer workspace unreachable"}) - return r - - async def fake_get(url, **kwargs): - r = MagicMock() - r.status_code = 200 - r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"}) - return r - - mc.post = fake_post - mc.get = fake_get - - with patch("a2a_tools.httpx.AsyncClient", return_value=mc): - result = await a2a_tools.delegate_task("ws-peer-123", "do a thing") - - assert "Error" in result - assert "peer workspace unreachable" in result - - async def test_dict_form_error_returns_error_message(self): - """{"error": {"message": "...", "code": ...}} — the pre-existing path.""" - import a2a_tools - - mc = AsyncMock() - mc.__aenter__ = AsyncMock(return_value=mc) - mc.__aexit__ = AsyncMock(return_value=False) - - async def fake_post(url, **kwargs): - r = MagicMock() - r.status_code = 200 - r.json = MagicMock(return_value={"error": {"message": "internal server error", "code": 500}}) - return r - - async def fake_get(url, **kwargs): - r = MagicMock() - r.status_code = 200 - r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"}) - return r - - mc.post = fake_post - mc.get = fake_get - - with patch("a2a_tools.httpx.AsyncClient", return_value=mc): - result = await a2a_tools.delegate_task("ws-peer-456", "do a thing") - - assert "Error" in result - assert "internal server error" in result - - async def test_success_returns_result_text(self): - """Happy path: result with parts returns the first text part.""" - import a2a_tools - - mc = AsyncMock() - mc.__aenter__ = AsyncMock(return_value=mc) - mc.__aexit__ = AsyncMock(return_value=False) - - async def fake_post(url, **kwargs): - r = MagicMock() - r.status_code = 200 - r.json = MagicMock(return_value={ - "result": { - "parts": [{"kind": "text", "text": "Task done!"}] - } - }) - return r - - async def fake_get(url, **kwargs): - r = MagicMock() - r.status_code = 200 - r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"}) - return r - - mc.post = fake_post - mc.get = fake_get - - with patch("a2a_tools.httpx.AsyncClient", return_value=mc): - result = await a2a_tools.delegate_task("ws-peer-789", "do a thing") - - assert result == "Task done!" - - # --------------------------------------------------------------------------- # tool_delegate_task_async # --------------------------------------------------------------------------- -- 2.45.2