From b9caae1e5053a10fb94b28e0551b47b4ef811f0e Mon Sep 17 00:00:00 2001 From: jinliyl <6469360+jinliyl@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:26:33 +0800 Subject: [PATCH] feat(service): support explicit wildcard network binds (#547) * feat(service): bind network services to all interfaces * fix(service): keep network listeners local by default * fix(service): make remote access explicit --- docs/en/services.md | 7 +++-- docs/zh/services.md | 6 +++-- reme/components/client/base_client.py | 21 +++++++++++++++ reme/components/client/http_client.py | 16 +----------- reme/components/client/mcp_client.py | 17 +----------- reme/components/service/base_service.py | 9 ++++--- reme/constants.py | 11 ++++++++ reme/utils/logo_utils.py | 14 +++++++--- reme/utils/service_utils.py | 15 +++++++---- reme_studio/README.md | 16 ++++++++++++ reme_studio/README_ZH.md | 15 +++++++++++ reme_studio/package.json | 2 ++ reme_studio/tests/network-binding.test.mjs | 15 +++++++++++ tests/unit/test_http_client.py | 15 +++++++++++ tests/unit/test_logo_utils.py | 8 +++--- tests/unit/test_mcp_client.py | 20 +++++++++++++++ tests/unit/test_service.py | 30 +++++++++++++++++++++- tests/unit/test_service_utils.py | 17 ++++++++++++ 18 files changed, 202 insertions(+), 52 deletions(-) create mode 100644 reme_studio/tests/network-binding.test.mjs create mode 100644 tests/unit/test_mcp_client.py diff --git a/docs/en/services.md b/docs/en/services.md index c0be01a0..ad597eda 100644 --- a/docs/en/services.md +++ b/docs/en/services.md @@ -5,7 +5,8 @@ description: Run ReMe through HTTP, SSE, MCP, the CLI, and ReMe Studio while res # Services and Deployment -ReMe can run as a local HTTP service, a standalone MCP server, or a one-shot CLI Job. The default starts HTTP on `127.0.0.1:2333` and serves JSON, SSE, streamable HTTP MCP, and optional ReMe Studio from one process. +ReMe can run as a local HTTP service, a standalone MCP server, or a one-shot CLI Job. By default HTTP binds to +`127.0.0.1:2333`; one process serves JSON, SSE, streamable HTTP MCP, and optional ReMe Studio. ## HTTP API @@ -108,7 +109,9 @@ ReMe is local-first: - Jobs may write, move, or delete files; - the service layer has no general-purpose user authentication. -Do not expose the default service directly to the public internet. For remote access, place it on a controlled network or behind an authenticated TLS reverse proxy, apply access controls and request-size limits, and expose only necessary Jobs. +Remote access must be enabled explicitly with `reme start service.host=0.0.0.0`. Do not expose the service directly to +the public internet. Place it on a controlled network or behind an authenticated TLS reverse proxy, apply access +controls and request-size limits, and expose only necessary Jobs. ## OpenAPI diff --git a/docs/zh/services.md b/docs/zh/services.md index 9cfea053..400648a1 100644 --- a/docs/zh/services.md +++ b/docs/zh/services.md @@ -5,7 +5,8 @@ description: 使用 ReMe 的 HTTP、SSE、MCP 和 Studio 服务,并理解默 # 服务与部署 -ReMe 可以作为本地 HTTP 服务、独立 MCP Server 或一次性 CLI Job 运行。默认模式是在 `127.0.0.1:2333` 启动 HTTP 服务,并在同一进程中提供 JSON API、SSE、MCP 与可选的 ReMe Studio。 +ReMe 可以作为本地 HTTP 服务、独立 MCP Server 或一次性 CLI Job 运行。默认 HTTP 服务监听 +`127.0.0.1:2333`;同一进程提供 JSON API、SSE、MCP 与可选的 ReMe Studio。 ## HTTP 服务 @@ -124,7 +125,8 @@ ReMe 默认定位为本地服务: - Job 可进行文件写入、移动和删除; - 当前服务层不提供通用用户认证。 -不要直接把默认服务暴露到公网。需要远程访问时,在受控网络或带身份认证、TLS、访问控制和请求大小限制的反向代理后部署,并通过 `service.jobs` 只开放必要 Job。 +远程访问必须通过 `reme start service.host=0.0.0.0` 显式启用。不要直接把服务暴露到公网。请在受控网络或带 +身份认证、TLS、访问控制和请求大小限制的反向代理后部署,并通过 `service.jobs` 只开放必要 Job。 ## OpenAPI diff --git a/reme/components/client/base_client.py b/reme/components/client/base_client.py index 350c51eb..87d7a35e 100644 --- a/reme/components/client/base_client.py +++ b/reme/components/client/base_client.py @@ -1,10 +1,17 @@ """Base client abstraction.""" import json +import os from abc import abstractmethod from collections.abc import AsyncGenerator from ..base_component import BaseComponent +from ...constants import ( + REME_DEFAULT_HOST, + REME_DEFAULT_PORT, + REME_SERVICE_INFO, + normalize_connect_host, +) from ...enumeration import ComponentEnum @@ -17,6 +24,20 @@ class BaseClient(BaseComponent): super().__init__(**kwargs) self.client = None + def _resolve_service_address(self, host: str | None, port: int | None) -> tuple[str, int]: + """Resolve explicit, discovered, or default network coordinates.""" + if not (host and port): + if service_info := os.environ.get(REME_SERVICE_INFO): + try: + data = json.loads(service_info) + host, port = data["host"], data["port"] + except Exception: + self.logger.warning(f"Invalid service info: {service_info}") + host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT + else: + host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT + return normalize_connect_host(host), port + 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 ff3a6233..ef4da0a9 100644 --- a/reme/components/client/http_client.py +++ b/reme/components/client/http_client.py @@ -1,14 +1,12 @@ """HTTP client for ReMe services.""" import json -import os from collections.abc import AsyncGenerator import httpx from .base_client import BaseClient from ..component_registry import R -from ...constants import REME_SERVICE_INFO, REME_DEFAULT_HOST, REME_DEFAULT_PORT from ...enumeration import ChunkEnum from ...schema import StreamChunk @@ -27,19 +25,7 @@ class HttpClient(BaseClient): ): super().__init__(**kwargs) - # Resolve host/port: explicit args > env var > defaults - if not (host and port): - if service_info := os.environ.get(REME_SERVICE_INFO): - try: - data = json.loads(service_info) - host = data["host"] - port = data["port"] - except Exception: - self.logger.warning(f"Invalid service info: {service_info}") - host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT - else: - host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT - + host, port = self._resolve_service_address(host, port) self.base_url = f"http://{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 6b3b117d..72e41f2b 100644 --- a/reme/components/client/mcp_client.py +++ b/reme/components/client/mcp_client.py @@ -1,13 +1,10 @@ """MCP client for ReMe services.""" -import json -import os from collections.abc import AsyncGenerator from typing import TYPE_CHECKING, Any from .base_client import BaseClient from ..component_registry import R -from ...constants import REME_SERVICE_INFO, REME_DEFAULT_HOST, REME_DEFAULT_PORT if TYPE_CHECKING: from fastmcp.client.client import CallToolResult @@ -51,19 +48,7 @@ class MCPClient(BaseClient): raise ValueError(f"Unknown transport: {transport!r}, expected one of {sorted(_VALID_TRANSPORTS)}") if isinstance(transport, str) and transport != "stdio": - if not (host and port): - if service_info := os.environ.get(REME_SERVICE_INFO): - try: - data = json.loads(service_info) - host = data["host"] - port = data["port"] - except Exception: - self.logger.warning(f"Invalid service info: {service_info}") - host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT - else: - host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT - self.host = host - self.port = port + self.host, self.port = self._resolve_service_address(host, port) self.transport = transport self.timeout = timeout diff --git a/reme/components/service/base_service.py b/reme/components/service/base_service.py index fed150e2..2bf84833 100644 --- a/reme/components/service/base_service.py +++ b/reme/components/service/base_service.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from ..base_component import BaseComponent from ..job.base_job import BaseJob -from ...constants import REME_SERVICE_INFO +from ...constants import REME_SERVICE_INFO, normalize_connect_host from ...enumeration import ComponentEnum if TYPE_CHECKING: @@ -52,15 +52,16 @@ class BaseService(BaseComponent): def _lifespan(self, app: "Application", host: str, port: int): """Build an async-context lifespan that brackets the server with app start/close. - Publishes the bound address via the REME_SERVICE_INFO environment variable so - in-process clients can discover where this service is listening. + Publishes a connectable address via the REME_SERVICE_INFO environment variable + so in-process clients can discover the service even when it binds a wildcard. """ @asynccontextmanager async def lifespan(_): await app.start() try: - service_info = json.dumps({"host": host, "port": port}) + advertised_host = normalize_connect_host(host) + service_info = json.dumps({"host": advertised_host, "port": port}) os.environ[REME_SERVICE_INFO] = service_info self.logger.info(f"{self.name} started: {REME_SERVICE_INFO}={service_info}") yield diff --git a/reme/constants.py b/reme/constants.py index d58a3986..a3839beb 100644 --- a/reme/constants.py +++ b/reme/constants.py @@ -2,10 +2,21 @@ 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" +# Wildcard address accepted when a service is explicitly configured to listen +# on every IPv4 interface. +REME_WILDCARD_BIND_HOST = "0.0.0.0" + 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 + + # CRUD steps: file IO limits and truncation marker (shared across CRUD steps). DEFAULT_MAX_BYTES = 50 * 1024 MAX_FILE_READ_BYTES = 200 * 1024 * 1024 diff --git a/reme/utils/logo_utils.py b/reme/utils/logo_utils.py index f7eaee5a..aebcde01 100644 --- a/reme/utils/logo_utils.py +++ b/reme/utils/logo_utils.py @@ -10,7 +10,11 @@ from rich.panel import Panel from rich.table import Table from rich.text import Text -from ..constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT +from ..constants import ( + REME_DEFAULT_HOST, + REME_DEFAULT_PORT, + normalize_connect_host, +) if TYPE_CHECKING: from ..components.service import BaseService @@ -78,11 +82,12 @@ def print_logo(app_config: "ApplicationConfig", runtime_service: "BaseService | case "http": host = getattr(runtime_service, "host", extra.get("host", REME_DEFAULT_HOST)) port = getattr(runtime_service, "port", extra.get("port", REME_DEFAULT_PORT)) - info_table.add_row("🔗", "URL:", f"http://{host}:{port}") + display_host = normalize_connect_host(host) + info_table.add_row("🔗", "URL:", f"http://{display_host}:{port}") mcp_enabled = getattr(runtime_service, "mcp_enabled", extra.get("mcp_enabled", True)) if mcp_enabled: mcp_path = getattr(runtime_service, "mcp_path", extra.get("mcp_path", "/mcp")) - info_table.add_row("🚌", "MCP:", f"http://{host}:{port}{mcp_path}") + info_table.add_row("🚌", "MCP:", f"http://{display_host}:{port}{mcp_path}") info_table.add_row("📚", "FastAPI:", Text(get_version("fastapi"), style="dim")) if mcp_enabled: info_table.add_row("📚", "FastMCP:", Text(get_version("fastmcp"), style="dim")) @@ -92,7 +97,8 @@ def print_logo(app_config: "ApplicationConfig", runtime_service: "BaseService | if transport != "stdio": host = getattr(runtime_service, "host", extra.get("host", REME_DEFAULT_HOST)) port = getattr(runtime_service, "port", extra.get("port", REME_DEFAULT_PORT)) - url = f"http://{host}:{port}" + display_host = normalize_connect_host(host) + url = f"http://{display_host}:{port}" if transport == "sse": url += "/sse" info_table.add_row("🔗", "URL:", url) diff --git a/reme/utils/service_utils.py b/reme/utils/service_utils.py index 6b44b838..457b276d 100644 --- a/reme/utils/service_utils.py +++ b/reme/utils/service_utils.py @@ -6,7 +6,11 @@ import sys import psutil -from ..constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT +from ..constants import ( + REME_DEFAULT_HOST, + REME_DEFAULT_PORT, + normalize_connect_host, +) async def find_reme(host: str, port: int) -> str: @@ -14,7 +18,7 @@ async def find_reme(host: str, port: int) -> str: from ..components.client.http_client import HttpClient try: - async with HttpClient(host=host, port=port, timeout=2.0) as client: + async with HttpClient(host=normalize_connect_host(host), port=port, timeout=2.0) as client: async for _ in client(action="health_check"): break return "reme" @@ -59,7 +63,8 @@ def _scan_reme_procs() -> list[tuple[int, str, int]]: if "start" not in cmdline or not any("reme" in tok for tok in cmdline): continue host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT - for t in cmdline: + for raw_arg in cmdline: + t = raw_arg.lstrip("-") if t.startswith("service.host="): host = t.split("=", 1)[1] elif t.startswith("service.port=") and t.split("=", 1)[1].isdigit(): @@ -124,7 +129,7 @@ async def locate_reme() -> tuple[str, int, int | None] | None: return REME_DEFAULT_HOST, REME_DEFAULT_PORT, _pid_on_port(REME_DEFAULT_PORT) for pid, host, port in _scan_reme_procs(): if await find_reme(host, port) == "reme": - return host, port, pid + return normalize_connect_host(host), port, pid return None @@ -135,7 +140,7 @@ def precheck_start(svc_config: dict | None) -> bool: port = int(port) status = asyncio.run(find_reme(host, port)) if status == "reme": - print(f"reme already running at {host}:{port}") + print(f"reme already running at {normalize_connect_host(host)}:{port}") return False if status == "occupied": print( diff --git a/reme_studio/README.md b/reme_studio/README.md index f9ddb5fa..b237475e 100644 --- a/reme_studio/README.md +++ b/reme_studio/README.md @@ -298,6 +298,20 @@ needed: NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:8000 npm run dev ``` +For development from another machine on a controlled network, bind both services explicitly and configure the browser +to use the ReMe host's reachable address (replace `192.168.1.10` as appropriate): + +```bash +# ReMe repository root, on the host running ReMe +reme start service.host=0.0.0.0 + +# reme_studio/, on the same host +NEXT_PUBLIC_REME_API_URL=http://192.168.1.10:2333 npm run dev:remote +``` + +The ReMe service has no general-purpose authentication. Do not use the remote development command on an untrusted +network or expose either endpoint directly to the public internet. + ## Build the ReMe-hosted static frontend ReMe can serve Studio from the same FastAPI process as its HTTP API. Build the static variant and restart ReMe: @@ -316,6 +330,8 @@ Open . The static build uses same-origin requests by defa VITE_REME_API_URL=http://127.0.0.1:2333 npm run dev:static ``` +Use `dev:static:remote` with a reachable `VITE_REME_API_URL` for explicit remote static development. + `npm run build` remains the vinext/Sites deployment build. `npm run build:static` creates `dist-static/` exclusively for FastAPI and Python/npm package distribution. Change frontend source rather than committing generated distribution files. diff --git a/reme_studio/README_ZH.md b/reme_studio/README_ZH.md index 9577686d..6c3799c9 100644 --- a/reme_studio/README_ZH.md +++ b/reme_studio/README_ZH.md @@ -263,6 +263,19 @@ npm run dev NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:8000 npm run dev ``` +需要从受控网络中的另一台机器进行开发时,应显式开放两个服务,并让浏览器连接 ReMe 主机的可达地址(请按 +实际情况替换 `192.168.1.10`): + +```bash +# 在运行 ReMe 的主机上,从仓库根目录执行 +reme start service.host=0.0.0.0 + +# 在同一主机的 reme_studio/ 目录执行 +NEXT_PUBLIC_REME_API_URL=http://192.168.1.10:2333 npm run dev:remote +``` + +ReMe 服务当前不提供通用身份认证。请勿在不可信网络中使用远程开发命令,也不要把任一端点直接暴露到公网。 + ## 构建由 ReMe 托管的静态前端 ReMe 可以使用提供 HTTP API 的同一个 FastAPI 进程托管 Studio。构建静态版本并重启 ReMe: @@ -281,6 +294,8 @@ reme start VITE_REME_API_URL=http://127.0.0.1:2333 npm run dev:static ``` +需要显式进行远程静态开发时,请使用 `dev:static:remote`,并将 `VITE_REME_API_URL` 设置为可达地址。 + `npm run build` 仍用于 vinext/Sites 部署构建;`npm run build:static` 仅为 FastAPI 以及 Python/npm 包分发生成 `dist-static/`。应修改前端源文件,而不是提交生成的分发文件。 ## 常见问题 diff --git a/reme_studio/package.json b/reme_studio/package.json index d1dcdebf..09e65a0a 100644 --- a/reme_studio/package.json +++ b/reme_studio/package.json @@ -22,7 +22,9 @@ }, "scripts": { "dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev --force", + "dev:remote": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev --force --hostname 0.0.0.0", "dev:static": "vite --config vite.static.config.ts", + "dev:static:remote": "vite --config vite.static.config.ts --host 0.0.0.0", "build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build", "build:static": "vite build --config vite.static.config.ts", "start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start", diff --git a/reme_studio/tests/network-binding.test.mjs b/reme_studio/tests/network-binding.test.mjs new file mode 100644 index 00000000..0a18cbcc --- /dev/null +++ b/reme_studio/tests/network-binding.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("Studio development servers require an explicit remote command", async () => { + const packageJson = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), + ); + const { scripts } = packageJson; + + assert.doesNotMatch(scripts.dev, /0\.0\.0\.0/); + assert.doesNotMatch(scripts["dev:static"], /0\.0\.0\.0/); + assert.match(scripts["dev:remote"], /--hostname 0\.0\.0\.0/); + assert.match(scripts["dev:static:remote"], /--host 0\.0\.0\.0/); +}); diff --git a/tests/unit/test_http_client.py b/tests/unit/test_http_client.py index a864c1b7..bd506cb0 100644 --- a/tests/unit/test_http_client.py +++ b/tests/unit/test_http_client.py @@ -3,6 +3,21 @@ # pylint: disable=protected-access from reme.components.client.http_client import HttpClient +from reme.constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT + + +def test_default_client_uses_loopback_address(): + """Clients connect to loopback when no service address is configured.""" + client = HttpClient() + + assert client.base_url == f"http://{REME_DEFAULT_HOST}:{REME_DEFAULT_PORT}" + + +def test_client_converts_wildcard_bind_address_to_loopback(): + """A service's wildcard bind address is not advertised as a destination.""" + client = HttpClient(host="0.0.0.0", port=8123) + + assert client.base_url == "http://127.0.0.1:8123" def test_format_for_display_hides_metadata_by_default(): diff --git a/tests/unit/test_logo_utils.py b/tests/unit/test_logo_utils.py index c1ac8e53..ede942bd 100644 --- a/tests/unit/test_logo_utils.py +++ b/tests/unit/test_logo_utils.py @@ -29,8 +29,9 @@ def test_logo_uses_runtime_http_address(monkeypatch) -> None: output = _render_logo(monkeypatch, config, runtime_service) - assert "http://0.0.0.0:8123" in output - assert "http://0.0.0.0:8123/mcp" in output + assert "http://127.0.0.1:8123" in output + assert "http://127.0.0.1:8123/mcp" in output + assert "http://0.0.0.0:8123" not in output def test_logo_fallback_matches_service_defaults(monkeypatch) -> None: @@ -62,7 +63,8 @@ def test_logo_uses_runtime_mcp_transport_and_address(monkeypatch) -> None: output = _render_logo(monkeypatch, config, runtime_service) assert "Transport: sse" in output - assert "http://0.0.0.0:8123/sse" in output + assert "http://127.0.0.1:8123/sse" in output + assert "http://0.0.0.0:8123/sse" not in output def test_logo_mcp_fallback_matches_service_defaults(monkeypatch) -> None: diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py new file mode 100644 index 00000000..7c6461b0 --- /dev/null +++ b/tests/unit/test_mcp_client.py @@ -0,0 +1,20 @@ +"""MCP client address defaults.""" + +from reme.components.client.mcp_client import MCPClient +from reme.constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT + + +def test_default_mcp_client_uses_loopback_address(): + """The network MCP client uses a connectable loopback destination.""" + client = MCPClient() + + assert client.host == REME_DEFAULT_HOST + assert client.port == REME_DEFAULT_PORT + + +def test_mcp_client_converts_wildcard_bind_address_to_loopback(): + """A wildcard service address is normalized before transport construction.""" + client = MCPClient(host="0.0.0.0", port=8123) + + assert client.host == "127.0.0.1" + assert client.port == 8123 diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 7dc4c294..4e6f07e2 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -1,13 +1,16 @@ """Tests for service job registration behavior.""" import asyncio +import json +import os from types import SimpleNamespace from unittest.mock import Mock import pytest from reme.components.job import BaseJob, StreamJob -from reme.components.service import MCPService +from reme.components.service import HttpService, MCPService +from reme.constants import REME_SERVICE_INFO from reme.schema import Response @@ -190,3 +193,28 @@ def test_service_lifespan_closes_app_after_error(): assert events == ["start", "close"] asyncio.run(run()) + + +def test_network_services_bind_loopback_by_default(): + """HTTP and network MCP services stay local unless remote access is explicit.""" + assert HttpService().host == "127.0.0.1" + assert MCPService().host == "127.0.0.1" + + +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" + + +def test_service_lifespan_advertises_loopback_for_wildcard_bind(monkeypatch): + """In-process clients receive a connectable address, not the wildcard bind address.""" + monkeypatch.delenv(REME_SERVICE_INFO, raising=False) + + async def run(): + app = _dummy_app() + lifespan = HttpService()._lifespan(app, "0.0.0.0", 8123) # pylint: disable=protected-access + async with lifespan(None): + assert json.loads(os.environ[REME_SERVICE_INFO]) == {"host": "127.0.0.1", "port": 8123} + + asyncio.run(run()) diff --git a/tests/unit/test_service_utils.py b/tests/unit/test_service_utils.py index b61a1fca..32f5dea2 100644 --- a/tests/unit/test_service_utils.py +++ b/tests/unit/test_service_utils.py @@ -18,6 +18,7 @@ from types import SimpleNamespace import psutil +from reme.constants import normalize_connect_host from reme.utils import service_utils as su # ---------------------------------------------------------------------- @@ -125,6 +126,17 @@ def test_scan_reme_procs_parses_host_and_port(monkeypatch): assert su._scan_reme_procs() == [(123, "0.0.0.0", 8123)] +def test_scan_reme_procs_accepts_prefixed_cli_arguments(monkeypatch): + procs = [ + _FakeProc( + 123, + cmdline=["reme", "start", "--service.host=0.0.0.0", "-service.port=8123"], + ), + ] + _patch_iter(monkeypatch, procs) + assert su._scan_reme_procs() == [(123, "0.0.0.0", 8123)] + + def test_scan_reme_procs_defaults_when_args_absent(monkeypatch): procs = [_FakeProc(7, cmdline=["reme", "start"])] _patch_iter(monkeypatch, procs) @@ -158,6 +170,11 @@ def test_scan_reme_procs_skips_access_denied(monkeypatch): assert su._scan_reme_procs() == [(5, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)] +def test_connect_host_converts_wildcard_bind_address(): + assert normalize_connect_host("0.0.0.0") == "127.0.0.1" + assert normalize_connect_host("192.0.2.10") == "192.0.2.10" + + 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"]])