This commit is contained in:
Adarsh 2026-11-07 01:26:12 +00:00 • committed by GitHub
commit 9873338bad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 119 additions and 7 deletions

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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).

View file

@ -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"

View file

@ -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")

View file

@ -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."""

View file

@ -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())

View file

@ -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"]])