From cf31da0dd34fa2876a56cf479d0e2aec41bf07f8 Mon Sep 17 00:00:00 2001 From: adarshh347 Date: Thu, 5 Nov 2026 15:51:24 +0530 Subject: [PATCH 1/3] fix(client): format IPv6 service URLs correctly --- docs/en/services.md | 2 ++ docs/zh/services.md | 2 ++ reme/components/client/base_client.py | 7 +++++++ reme/components/client/http_client.py | 2 +- reme/components/client/mcp_client.py | 2 +- tests/unit/test_base_client.py | 12 ++++++++++++ tests/unit/test_mcp_client.py | 13 +++++++++++++ 7 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/en/services.md b/docs/en/services.md index fa49b7c0..701794a5 100644 --- a/docs/en/services.md +++ b/docs/en/services.md @@ -17,6 +17,8 @@ reme start reme start service.host=127.0.0.1 service.port=8181 ``` +IPv6 literals such as `::1` can be used as the host; ReMe formats them correctly when HTTP or MCP clients connect. + From another terminal, call a Job through the CLI client: ```bash diff --git a/docs/zh/services.md b/docs/zh/services.md index 1cba192e..53ef518d 100644 --- a/docs/zh/services.md +++ b/docs/zh/services.md @@ -17,6 +17,8 @@ reme start reme start service.host=127.0.0.1 service.port=8181 ``` +主机地址也可以使用 `::1` 等 IPv6 字面量;HTTP 和 MCP client 会在连接时正确格式化该地址。 + 在另一个终端通过 CLI client 调用 Job: ```bash diff --git a/reme/components/client/base_client.py b/reme/components/client/base_client.py index a3b34879..e2296d06 100644 --- a/reme/components/client/base_client.py +++ b/reme/components/client/base_client.py @@ -5,6 +5,8 @@ import os from abc import abstractmethod from collections.abc import AsyncGenerator +import httpx + from ..base_component import BaseComponent from ...constants import ( REME_DEFAULT_HOST, @@ -38,6 +40,11 @@ class BaseClient(BaseComponent): port = port or fallback_port return normalize_connect_host(host), port + @staticmethod + def _build_service_url(host: str, port: int, path: str = "") -> str: + """Build a valid HTTP URL for the resolved service address, including IPv6 literals.""" + return str(httpx.URL(scheme="http", host=host, port=port, path=path)) + async def _start(self) -> None: """Initialize the client.""" diff --git a/reme/components/client/http_client.py b/reme/components/client/http_client.py index ccd7ee62..d9ae1b50 100644 --- a/reme/components/client/http_client.py +++ b/reme/components/client/http_client.py @@ -26,7 +26,7 @@ class HttpClient(BaseClient): super().__init__(**kwargs) host, port = self._resolve_service_address(host, port) - self.base_url = f"http://{host}:{port}" + self.base_url = self._build_service_url(host, port) self.timeout = timeout self.show_metadata = show_metadata diff --git a/reme/components/client/mcp_client.py b/reme/components/client/mcp_client.py index 5497381f..513003fb 100644 --- a/reme/components/client/mcp_client.py +++ b/reme/components/client/mcp_client.py @@ -72,7 +72,7 @@ class MCPClient(BaseClient): return cls(command=command, args=args) path = "/sse" if self.transport == "sse" else "/mcp" - url = f"http://{self.host}:{self.port}{path}" + url = self._build_service_url(self.host, self.port, path) return cls(url=url) # pylint: disable=unnecessary-dunder-call diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index ada9e8a2..678a2311 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -31,6 +31,11 @@ def test_explicit_host_only_keeps_host_and_defaults_port(): assert client.port == REME_DEFAULT_PORT +def test_ipv6_host_builds_a_valid_http_base_url(): + """IPv6 literals are bracketed by URL construction rather than string interpolation.""" + assert HttpClient(host="::1", port=8123).base_url == "http://[::1]:8123" + + def test_explicit_fields_win_over_service_discovery(monkeypatch): """Discovery fills only the missing field; explicit fields are kept.""" monkeypatch.setenv(REME_SERVICE_INFO, '{"host": "10.0.0.1", "port": 9000}') @@ -59,6 +64,13 @@ def test_service_discovery_fills_both_fields_when_nothing_is_explicit(monkeypatc assert client.port == 9000 +def test_ipv6_service_discovery_builds_a_valid_http_base_url(monkeypatch): + """Discovered IPv6 service addresses use the same URL-safe construction.""" + monkeypatch.setenv(REME_SERVICE_INFO, '{"host": "::1", "port": 9000}') + + assert HttpClient().base_url == "http://[::1]:9000" + + def test_invalid_service_info_keeps_explicit_fields_and_defaults_the_rest(monkeypatch): """An undecodable discovery payload still preserves explicit fields.""" monkeypatch.setenv(REME_SERVICE_INFO, "not-json") diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index a7e31f17..e9f03344 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -1,5 +1,7 @@ """MCP client addresses and SDK tool descriptors.""" +# pylint: disable=protected-access + from types import SimpleNamespace from unittest.mock import AsyncMock @@ -26,6 +28,17 @@ def test_mcp_client_converts_wildcard_bind_address_to_loopback(): assert client.port == 8123 +@pytest.mark.parametrize( + ("transport", "expected_path"), + [("sse", "/sse"), ("streamable-http", "/mcp")], +) +def test_mcp_client_brackets_ipv6_service_addresses(transport, expected_path): + """Both network MCP transports construct valid URLs for IPv6 literals.""" + client = MCPClient(transport=transport, host="::1", port=8123) + + assert client._build_transport().url == f"http://[::1]:8123{expected_path}" + + @pytest.mark.asyncio async def test_mcp_catalog_uses_sdk_field_names(): """Tool catalogs expose SDK 2 fields while preserving schemas and user metadata.""" From 609d6ab56e5dc090041a777f2ebe2a3600fcf6ad Mon Sep 17 00:00:00 2001 From: adarshh347 Date: Fri, 6 Nov 2026 00:03:21 +0530 Subject: [PATCH 2/3] fix(service): normalize IPv6 wildcard hosts --- docs/en/services.md | 1 + docs/zh/services.md | 1 + reme/components/codex_app_server.py | 1 - reme/constants.py | 8 ++++- reme/utils/service_utils.py | 3 +- tests/unit/test_base_client.py | 8 +++++ tests/unit/test_service.py | 15 ++++++++++ tests/unit/test_service_utils.py | 46 +++++++++++++++++++++++++++++ 8 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/en/services.md b/docs/en/services.md index 701794a5..e29edc95 100644 --- a/docs/en/services.md +++ b/docs/en/services.md @@ -18,6 +18,7 @@ reme start service.host=127.0.0.1 service.port=8181 ``` IPv6 literals such as `::1` can be used as the host; ReMe formats them correctly when HTTP or MCP clients connect. +If a service binds to the IPv6 wildcard `::`, in-process clients use the connectable loopback address `::1`. From another terminal, call a Job through the CLI client: diff --git a/docs/zh/services.md b/docs/zh/services.md index 53ef518d..e2e70b13 100644 --- a/docs/zh/services.md +++ b/docs/zh/services.md @@ -18,6 +18,7 @@ reme start service.host=127.0.0.1 service.port=8181 ``` 主机地址也可以使用 `::1` 等 IPv6 字面量;HTTP 和 MCP client 会在连接时正确格式化该地址。 +如果服务绑定到 IPv6 通配地址 `::`,ReMe 会向进程内 client 提供可连接的回环地址 `::1`。 在另一个终端通过 CLI client 调用 Job: diff --git a/reme/components/codex_app_server.py b/reme/components/codex_app_server.py index 91fa7532..18ac987c 100644 --- a/reme/components/codex_app_server.py +++ b/reme/components/codex_app_server.py @@ -320,7 +320,6 @@ class CodexAppServer(BaseComponent): ): if self._http_url is None: host = normalize_connect_host(service.host) - host = "::1" if host == "::" else host # Background jobs can run before uvicorn finishes binding the main listener. async with asyncio.timeout(30): while True: diff --git a/reme/constants.py b/reme/constants.py index a3839beb..536bfaf4 100644 --- a/reme/constants.py +++ b/reme/constants.py @@ -4,17 +4,23 @@ REME_SERVICE_INFO = "REME_SERVICE_INFO" # Loopback address used by services and clients unless a host is configured. REME_DEFAULT_HOST = "127.0.0.1" +REME_IPV6_LOOPBACK_HOST = "::1" # Wildcard address accepted when a service is explicitly configured to listen # on every IPv4 interface. REME_WILDCARD_BIND_HOST = "0.0.0.0" +REME_IPV6_WILDCARD_BIND_HOST = "::" REME_DEFAULT_PORT = 2333 def normalize_connect_host(host: str) -> str: """Return a local destination for an address used only for binding.""" - return REME_DEFAULT_HOST if host == REME_WILDCARD_BIND_HOST else host + if host == REME_WILDCARD_BIND_HOST: + return REME_DEFAULT_HOST + if host == REME_IPV6_WILDCARD_BIND_HOST: + return REME_IPV6_LOOPBACK_HOST + return host # CRUD steps: file IO limits and truncation marker (shared across CRUD steps). diff --git a/reme/utils/service_utils.py b/reme/utils/service_utils.py index 9e79187d..d598832b 100644 --- a/reme/utils/service_utils.py +++ b/reme/utils/service_utils.py @@ -24,7 +24,8 @@ async def find_reme(host: str, port: int) -> str: return "reme" except Exception: pass - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + family = socket.AF_INET6 if ":" in host else socket.AF_INET + with socket.socket(family, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind((host, port)) diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index 678a2311..a535be37 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -36,6 +36,14 @@ def test_ipv6_host_builds_a_valid_http_base_url(): assert HttpClient(host="::1", port=8123).base_url == "http://[::1]:8123" +def test_ipv6_wildcard_bind_host_resolves_to_loopback_destination(): + """An IPv6 wildcard listener advertises a connectable IPv6 loopback address.""" + assert HttpClient(host="::", port=8123).base_url == "http://[::1]:8123" + + client = MCPClient(host="::", port=8123) + assert client.host == "::1" + + def test_explicit_fields_win_over_service_discovery(monkeypatch): """Discovery fills only the missing field; explicit fields are kept.""" monkeypatch.setenv(REME_SERVICE_INFO, '{"host": "10.0.0.1", "port": 9000}') diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 07b45d94..106ec560 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -231,6 +231,8 @@ def test_network_services_accept_explicit_wildcard_bind(): """Remote access remains available through explicit service configuration.""" assert HttpService(host="0.0.0.0").host == "0.0.0.0" assert MCPService(host="0.0.0.0").host == "0.0.0.0" + assert HttpService(host="::").host == "::" + assert MCPService(host="::").host == "::" def test_service_lifespan_advertises_loopback_for_wildcard_bind(monkeypatch): @@ -244,3 +246,16 @@ def test_service_lifespan_advertises_loopback_for_wildcard_bind(monkeypatch): assert json.loads(os.environ[REME_SERVICE_INFO]) == {"host": "127.0.0.1", "port": 8123} asyncio.run(run()) + + +def test_service_lifespan_advertises_ipv6_loopback_for_ipv6_wildcard(monkeypatch): + """IPv6 wildcard binds are translated to a connectable in-process address.""" + monkeypatch.delenv(REME_SERVICE_INFO, raising=False) + + async def run(): + app = _dummy_app() + lifespan = HttpService()._lifespan(app, "::", 8123) # pylint: disable=protected-access + async with lifespan(None): + assert json.loads(os.environ[REME_SERVICE_INFO]) == {"host": "::1", "port": 8123} + + asyncio.run(run()) diff --git a/tests/unit/test_service_utils.py b/tests/unit/test_service_utils.py index fbed446a..faffce62 100644 --- a/tests/unit/test_service_utils.py +++ b/tests/unit/test_service_utils.py @@ -174,9 +174,55 @@ def test_scan_reme_procs_skips_access_denied(monkeypatch): def test_connect_host_converts_wildcard_bind_address(): assert normalize_connect_host("0.0.0.0") == "127.0.0.1" + assert normalize_connect_host("::") == "::1" assert normalize_connect_host("192.0.2.10") == "192.0.2.10" +@pytest.mark.asyncio +async def test_find_reme_uses_ipv6_for_wildcard_listener_probe(monkeypatch): + """IPv6 listener probes use IPv6 sockets and clients connect through loopback.""" + probed_hosts = [] + + class FailedProbeClient: + """Client probe that simulates no running service.""" + + def __init__(self, *, host, **_kwargs): + probed_hosts.append(host) + + async def __aenter__(self): + raise OSError("no running service") + + async def __aexit__(self, *_args): + return None + + socket_families = [] + + class FakeSocket: + """Socket probe that records the selected address family.""" + + def __init__(self, family, _kind): + socket_families.append(family) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def setsockopt(self, *_args): + return None + + def bind(self, address): + assert address == ("::", 8123) + + monkeypatch.setattr("reme.components.client.http_client.HttpClient", FailedProbeClient) + monkeypatch.setattr(su.socket, "socket", FakeSocket) + + assert await su.find_reme("::", 8123) == "free" + assert probed_hosts == ["::1"] + assert socket_families == [socket.AF_INET6] + + def test_running_app_config_preserves_plugins(monkeypatch): """Process replay exposes the full config while the compatibility helper returns service only.""" monkeypatch.setattr(su, "_reme_start_argv", lambda: [["config=example"]]) From 9aa54217cfb7f3d73983312c44df504db61b3615 Mon Sep 17 00:00:00 2001 From: adarshh347 Date: Fri, 6 Nov 2026 00:09:11 +0530 Subject: [PATCH 3/3] fix(service): probe IPv6 ports on loopback only --- reme/constants.py | 2 +- reme/utils/service_utils.py | 3 ++- tests/unit/test_service_utils.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/reme/constants.py b/reme/constants.py index 536bfaf4..4e7635af 100644 --- a/reme/constants.py +++ b/reme/constants.py @@ -15,7 +15,7 @@ REME_DEFAULT_PORT = 2333 def normalize_connect_host(host: str) -> str: - """Return a local destination for an address used only for binding.""" + """Return a local destination for a configured bind address.""" if host == REME_WILDCARD_BIND_HOST: return REME_DEFAULT_HOST if host == REME_IPV6_WILDCARD_BIND_HOST: diff --git a/reme/utils/service_utils.py b/reme/utils/service_utils.py index d598832b..cdce6ecf 100644 --- a/reme/utils/service_utils.py +++ b/reme/utils/service_utils.py @@ -25,10 +25,11 @@ async def find_reme(host: str, port: int) -> str: except Exception: pass family = socket.AF_INET6 if ":" in host else socket.AF_INET + probe_host = normalize_connect_host(host) with socket.socket(family, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: - s.bind((host, port)) + s.bind((probe_host, port)) return "free" except OSError: return "occupied" diff --git a/tests/unit/test_service_utils.py b/tests/unit/test_service_utils.py index faffce62..5c6df774 100644 --- a/tests/unit/test_service_utils.py +++ b/tests/unit/test_service_utils.py @@ -213,7 +213,7 @@ async def test_find_reme_uses_ipv6_for_wildcard_listener_probe(monkeypatch): return None def bind(self, address): - assert address == ("::", 8123) + assert address == ("::1", 8123) monkeypatch.setattr("reme.components.client.http_client.HttpClient", FailedProbeClient) monkeypatch.setattr(su.socket, "socket", FakeSocket)