mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-13 08:55:14 +00:00
Merge 9aa54217cf into e3fade0a78
This commit is contained in:
commit
9873338bad
12 changed files with 119 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"]])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue