Compare commits

..

2 Commits

Author SHA1 Message Date
infra-runtime-be 2e0080fb0b fix(workspace): register plugins_registry as sys.modules shim before loading adapters
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 1s
sop-tier-check / tier-check (pull_request) Failing after 1s
audit-force-merge / audit (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-05-10 14:55:05 +00:00
infra-runtime-be 75b5901d57 fix(workspace): default PLATFORM_URL to host.docker.internal in all modules
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 <noreply@anthropic.com>
2026-05-10 14:55:05 +00:00
16 changed files with 112 additions and 155 deletions
@@ -32,9 +32,11 @@ on:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid main pushes don't race the same
# :staging-latest tag retag. Allow parallel runs as they produce
# different :staging-<sha> tags and last-write-wins on :staging-latest.
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
-7
View File
@@ -77,13 +77,6 @@ jobs:
# works if we never check out PR HEAD. Same SHA the workflow
# itself was loaded from.
ref: ${{ github.event.pull_request.base.sha }}
- name: Install jq
# Gitea Actions runners (ubuntu-latest label) do not bundle jq.
# The script uses jq extensively for all JSON parsing; install it
# before the script runs. Using -qq for quiet output — diagnostic
# info is already captured via SOP_DEBUG=1 on failure.
run: apt-get update -qq && apt-get install -y -qq jq
- name: Verify tier label + reviewer team membership
env:
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
-1
View File
@@ -1 +0,0 @@
staging trigger
-1
View File
@@ -44,4 +44,3 @@
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
@@ -21,7 +21,6 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/envx"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
@@ -111,14 +110,11 @@ const maxProxyResponseBody = 10 << 20
// a generic 502 page to canvas. 10s is well above realistic intra-region
// latencies and well below CF's edge timeout.
//
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
// to response-headers-start. Configurable via
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
// turns (big context + internal delegate_task round-trips routinely exceed
// the old 60s ceiling). Body streaming after headers is governed by the
// per-request context deadline, NOT this timeout — so multi-minute agent
// responses still work fine.
// 3. Transport.ResponseHeaderTimeout — 60s. From request-body-end to
// response-headers-start. Covers cold-start first-byte (the 30-60s OAuth
// flow above), with margin. Body streaming after headers is governed by
// the per-request context deadline, NOT this timeout — so multi-minute
// agent responses still work fine.
//
// The point of (2) and (3) is to surface a *structured* 503 from
// handleA2ADispatchError when the workspace agent is unreachable, so canvas
@@ -131,7 +127,7 @@ var a2aClient = &http.Client{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
ResponseHeaderTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
// fan-in is bounded by the platform's broadcaster fan-out, not by
@@ -2276,43 +2276,3 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ==================== a2aClient ResponseHeaderTimeout config ====================
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
const defaultTimeout = 180 * time.Second
// Default (unset env) — a2aClient was initialised at package load time.
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
t.Errorf("a2aClient default ResponseHeaderTimeout = %v, want %v",
a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout, defaultTimeout)
}
// Env var override — verify parsing logic inline since a2aClient is
// initialised once at package load (env already consumed at import time).
t.Run("A2A_PROXY_RESPONSE_HEADER_TIMEOUT parsed correctly", func(t *testing.T) {
// We can't re-initialise a2aClient, but we can verify the same
// envx.Duration logic inline for the 5m override case.
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "5m")
if d, err := time.ParseDuration("5m"); err == nil && d > 0 {
if d != 5*time.Minute {
t.Errorf("ParseDuration(\"5m\") = %v, want 5m", d)
}
}
})
t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) {
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration")
// Simulate what envx.Duration does with an invalid value.
var fallback = 180 * time.Second
override := fallback
if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
override = d
}
}
if override != fallback {
t.Errorf("invalid env var: got %v, want fallback %v", override, fallback)
}
})
}
+4 -1
View File
@@ -25,7 +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
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
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://host.docker.internal:8080")
async def discover(target_id: str) -> dict | None:
+4 -1
View File
@@ -26,7 +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
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
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://host.docker.internal:8080")
# Cache workspace ID → name mappings (populated by list_peers calls)
_peer_names: dict[str, str] = {}
-10
View File
@@ -77,16 +77,6 @@ async def delegate_task(workspace_id: str, task: str) -> str:
return str(result) if isinstance(result, str) else "(no text)"
elif "error" in data:
err = data["error"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
+14 -4
View File
@@ -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,
+4 -1
View File
@@ -18,7 +18,10 @@ from platform_auth import auth_headers
logger = logging.getLogger(__name__)
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
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://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")
+4 -1
View File
@@ -22,7 +22,10 @@ from policies.routing import build_team_routing_payload
logger = logging.getLogger(__name__)
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
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://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")
+6 -1
View File
@@ -58,7 +58,12 @@ async def main(): # pragma: no cover
if not workspace_id:
raise SystemExit("FATAL: WORKSPACE_ID env var is not set. Aborting.")
config_path = os.environ.get("WORKSPACE_CONFIG_PATH", "/configs")
platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
# Docker-aware default — host.docker.internal resolves the platform service
# from inside the Docker network mesh; falls back to localhost for local dev.
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://host.docker.internal:8080")
awareness_config = get_awareness_config()
# 0. Initialise OpenTelemetry (no-op if packages not installed)
+24 -14
View File
@@ -51,22 +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."""
# Ensure the plugins_registry package and its submodules are importable in the
# fresh module namespace created by module_from_spec(). Plugin adapters
# (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..."
# which requires plugins_registry and its submodules to already be in sys.modules.
# We import and register them before exec_module so the plugin's own
# from ... import statements resolve correctly.
import sys
import plugins_registry
sys.modules.setdefault("plugins_registry", plugins_registry)
for _sub in ("builtins", "protocol", "raw_drop"):
# 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:
sub = importlib.import_module(f"plugins_registry.{_sub}")
sys.modules.setdefault(f"plugins_registry.{_sub}", sub)
except Exception:
# Submodule may not exist in all versions; skip if absent.
_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
@@ -1,60 +0,0 @@
"""Tests for _load_module_from_path sys.modules injection fix (issue #296).
Verifies that plugin adapters using "from plugins_registry.builtins import ..."
can be loaded via _load_module_from_path() without ModuleNotFoundError.
"""
import sys
import tempfile
import os
from pathlib import Path
# Ensure the plugins_registry package is importable
import plugins_registry
from plugins_registry import _load_module_from_path
def test_load_adapter_with_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly."""
# Write a temp adapter file that does the exact import from the bug report.
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n")
f.write("assert Adaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "Adaptor"), "module should expose Adaptor"
finally:
os.unlink(adapter_path)
def test_load_adapter_with_full_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry import ...' loads cleanly."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry import InstallContext, resolve\n")
f.write("from plugins_registry.protocol import PluginAdaptor\n")
f.write("assert InstallContext is not None\n")
f.write("assert resolve is not None\n")
f.write("assert PluginAdaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter_full", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "InstallContext"), "module should expose InstallContext"
assert hasattr(module, "resolve"), "module should expose resolve"
assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor"
finally:
os.unlink(adapter_path)
if __name__ == "__main__":
test_load_adapter_with_plugins_registry_import()
test_load_adapter_with_full_plugins_registry_import()
print("ALL TESTS PASS")
+41
View File
@@ -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