mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-30 01:52:29 +00:00
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
This commit is contained in:
parent
d67f1490f5
commit
b9caae1e50
18 changed files with 202 additions and 52 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 <http://127.0.0.1:2333>. 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/`。应修改前端源文件,而不是提交生成的分发文件。
|
||||
|
||||
## 常见问题
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
15
reme_studio/tests/network-binding.test.mjs
Normal file
15
reme_studio/tests/network-binding.test.mjs
Normal file
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
20
tests/unit/test_mcp_client.py
Normal file
20
tests/unit/test_mcp_client.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"]])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue