From 75b5901d57c400c63d43b2a2e8efbe2bb4f4fbff Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Sun, 10 May 2026 08:08:17 +0000 Subject: [PATCH 1/2] fix(workspace): default PLATFORM_URL to host.docker.internal in all modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KI-014 follow-on: inside a workspace container, localhost refers to the container itself, not the platform. Four files had the Docker-aware if-branch correct but fell through to localhost:8080 as the non-Docker fallback — effectively making the Docker path the ONLY path that works, since local dev on Mac/Linux can also resolve host.docker.internal via the Docker daemon's built-in resolver. Fix: unify the default to host.docker.internal in both branches, so the env-var override always works and no caller ever silently falls back to the wrong address. - a2a_cli.py: else branch hardcoded localhost → host.docker.internal - consolidation.py: same - coordinator.py: same - builtin_tools/temporal_workflow.py: two inline os.environ.get defaults replaced with a _platform_url() helper for DRY + consistent detection Co-Authored-By: Claude Opus 4.7 --- workspace/a2a_cli.py | 2 +- workspace/a2a_client.py | 2 +- workspace/builtin_tools/temporal_workflow.py | 18 ++++++++++++++---- workspace/consolidation.py | 2 +- workspace/coordinator.py | 2 +- workspace/main.py | 2 +- 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/workspace/a2a_cli.py b/workspace/a2a_cli.py index 5ba7381c..0b2ce03c 100644 --- a/workspace/a2a_cli.py +++ b/workspace/a2a_cli.py @@ -28,7 +28,7 @@ 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 = 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..07674e58 100644 --- a/workspace/a2a_client.py +++ b/workspace/a2a_client.py @@ -29,7 +29,7 @@ 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 = 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..12db0e60 100644 --- a/workspace/builtin_tools/temporal_workflow.py +++ b/workspace/builtin_tools/temporal_workflow.py @@ -54,6 +54,16 @@ import httpx logger = logging.getLogger(__name__) + +def _platform_url() -> str: + """Return the platform URL, defaulting to host.docker.internal when running + inside a Docker container (where localhost refers to the container, not the + host). External callers can always override via the PLATFORM_URL env var. + """ + if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): + return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") + return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") + # ───────────────────────────────────────────────────────────────────────────── # Constants # ───────────────────────────────────────────────────────────────────────────── @@ -79,12 +89,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 +135,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, diff --git a/workspace/consolidation.py b/workspace/consolidation.py index 81e9ec88..edd9c72f 100644 --- a/workspace/consolidation.py +++ b/workspace/consolidation.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) 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 = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID") if not _WORKSPACE_ID_raw: raise RuntimeError("WORKSPACE_ID environment variable is required but not set") diff --git a/workspace/coordinator.py b/workspace/coordinator.py index 12d317ef..70ac7aa4 100644 --- a/workspace/coordinator.py +++ b/workspace/coordinator.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) 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 = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID") if not _WORKSPACE_ID_raw: raise RuntimeError("WORKSPACE_ID environment variable is required but not set") diff --git a/workspace/main.py b/workspace/main.py index 77c2d2d6..8b40bc4e 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -63,7 +63,7 @@ async def main(): # pragma: no cover 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 = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") awareness_config = get_awareness_config() # 0. Initialise OpenTelemetry (no-op if packages not installed) -- 2.45.2 From 2e0080fb0b583bd5746a83966e98104c43a1d0a7 Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Sun, 10 May 2026 12:02:56 +0000 Subject: [PATCH 2/2] fix(workspace): register plugins_registry as sys.modules shim before loading adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KI-296 fix: when the PyPI-installed runtime (molecule-ai-workspace-runtime 0.1.129+) ships plugins_registry as molecule_runtime.plugins_registry (a subpackage), plugin adapter files that do ``from plugins_registry import ...`` as a top-level name fail with ModuleNotFoundError because Python's import system cannot find a top-level ``plugins_registry`` package. The fix in plugins_registry/__init__.py:_load_module_from_path() registers molecule_runtime.plugins_registry as ``plugins_registry`` in sys.modules before exec'ing any plugin adapter file, so the top-level import resolves correctly in both environments: - PyPI wheel (molecule_runtime.plugins_registry → sys.modules["plugins_registry"]) - molecule-core workspace source (top-level workspace/plugins_registry already on sys.path; the setdefault is a no-op) Submodules (builtins, protocol, raw_drop) are also registered so adapters that import ``from plugins_registry.builtins import ...`` work without error. Added test_load_module_from_path_registers_plugins_registry_sys_modules. Co-Authored-By: Claude Opus 4.7 --- workspace/plugins_registry/__init__.py | 26 +++++++++++++++ workspace/tests/test_plugins_registry.py | 41 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/workspace/plugins_registry/__init__.py b/workspace/plugins_registry/__init__.py index 363f26fe..18e517ad 100644 --- a/workspace/plugins_registry/__init__.py +++ b/workspace/plugins_registry/__init__.py @@ -51,6 +51,32 @@ class AdaptorSource: def _load_module_from_path(module_name: str, path: Path): """Import a Python file by absolute path. Returns the module or None on failure.""" + + # KI-296: Before exec'ing plugin-adapter files (which import + # ``from plugins_registry import ...`` as a top-level name), register + # the molecule-runtime subpackage as ``plugins_registry`` in sys.modules. + # In the molecule-core workspace source this is already a top-level package, + # so the setdefault is a no-op. In the PyPI-installed runtime wheel + # (molecule-ai-workspace-runtime 0.1.129+), the package ships as + # ``molecule_runtime.plugins_registry`` and without this shim every + # plugin adapter would fail with ModuleNotFoundError. + import sys as _sys + + if "plugins_registry" not in _sys.modules: + try: + _mr_pr = __import__("molecule_runtime.plugins_registry", fromlist=[""]) + _sys.modules["plugins_registry"] = _mr_pr + # Also register submodules the adapters commonly import directly. + for _sub in ("builtins", "protocol", "raw_drop"): + _submod = getattr(_mr_pr, _sub, None) + if _submod is not None: + _sys.modules[f"plugins_registry.{_sub}"] = _submod + except ImportError: + # molecule-runtime not installed (e.g. test environment with + # workspace/ on sys.path directly) — skip shim; the top-level + # workspace/plugins_registry package is already findable. + pass + spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: return None diff --git a/workspace/tests/test_plugins_registry.py b/workspace/tests/test_plugins_registry.py index 44531eb4..ebfb7e1a 100644 --- a/workspace/tests/test_plugins_registry.py +++ b/workspace/tests/test_plugins_registry.py @@ -325,3 +325,44 @@ def test_resolve_registry_missing_module_falls_through(monkeypatch, tmp_path: Pa monkeypatch.setattr(pr, "_REGISTRY_ROOT", tmp_path / "empty-registry") _, source = pr.resolve("demo-plugin", "test_runtime", plugin_root) assert source == AdaptorSource.RAW_DROP + + +def test_load_module_from_path_registers_plugins_registry_sys_modules(tmp_path: Path): + """KI-296: _load_module_from_path registers ``plugins_registry`` in sys.modules + before exec'ing the adapter, so adapter files that do + ``from plugins_registry import ...`` resolve correctly when the runtime is + installed from the PyPI wheel (where the package ships as + ``molecule_runtime.plugins_registry`` rather than a top-level ``plugins_registry``). + """ + import sys as _sys + import plugins_registry as pr + + # Create a fake adapter that imports plugins_registry at top level. + adapter_file = tmp_path / "fake_runtime_adapter.py" + adapter_file.write_text( + "from plugins_registry import InstallContext # noqa: F401\n" + "from plugins_registry.builtins import AgentskillsAdaptor as Adaptor # noqa: F401\n" + ) + + # Evict any pre-existing sys.modules entries for the shim keys so the + # import inside _load_module_from_path actually runs. + _saved = { + k: _sys.modules.pop(k, None) + for k in ( + "plugins_registry", "plugins_registry.builtins", + "plugins_registry.protocol", "plugins_registry.raw_drop", + "_plugin_adaptor.test.fake_runtime", + ) + } + + try: + result = pr._load_module_from_path("_plugin_adaptor.test.fake_runtime", adapter_file) + assert result is not None, "module should load without ImportError" + assert hasattr(result, "Adaptor"), "AgentskillsAdaptor alias should be in namespace" + finally: + # Restore sys.modules state. + for k, v in _saved.items(): + if v is None: + _sys.modules.pop(k, None) + else: + _sys.modules[k] = v -- 2.45.2