diff --git a/docs/en/services.md b/docs/en/services.md index a6d7be58..b0f420c3 100644 --- a/docs/en/services.md +++ b/docs/en/services.md @@ -17,6 +17,9 @@ 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. +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: ```bash diff --git a/docs/zh/services.md b/docs/zh/services.md index 44f0b17f..34e6a7ae 100644 --- a/docs/zh/services.md +++ b/docs/zh/services.md @@ -17,6 +17,9 @@ reme start reme start service.host=127.0.0.1 service.port=8181 ``` +主机地址也可以使用 `::1` 等 IPv6 字面量;HTTP 和 MCP client 会在连接时正确格式化该地址。 +如果服务绑定到 IPv6 通配地址 `::`,ReMe 会向进程内 client 提供可连接的回环地址 `::1`。 + 在另一个终端通过 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 b3f4277d..4eab90b4 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/reme/components/codex_app_server.py b/reme/components/codex_app_server.py index 1412b4b1..780359ea 100644 --- a/reme/components/codex_app_server.py +++ b/reme/components/codex_app_server.py @@ -429,7 +429,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..4e7635af 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 + """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: + 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..cdce6ecf 100644 --- a/reme/utils/service_utils.py +++ b/reme/utils/service_utils.py @@ -24,10 +24,12 @@ 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 + 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_base_client.py b/tests/unit/test_base_client.py index ada9e8a2..a535be37 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -31,6 +31,19 @@ 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_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}') @@ -59,6 +72,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 e96a1d3d..78558c7d 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -28,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.""" 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..5c6df774 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 == ("::1", 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"]])