From 0c21a01f6fdb4852ac26909c00fff50d098617b4 Mon Sep 17 00:00:00 2001 From: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:13:57 +0100 Subject: [PATCH] fix: default WebUI bind host back to loopback (#412) * fix: change default bind host to loopback for security across all components * fix: update documentation and tests for loopback host configuration and security warnings --- EvoScientist/cli/commands.py | 36 +++++++++++++---------- EvoScientist/config/settings.py | 24 +++++++-------- EvoScientist/deploy/server.py | 22 ++++++-------- EvoScientist/deploy/webui.py | 26 +++++++++-------- EvoScientist/langgraph_dev/manager.py | 35 +++++++++------------- README.md | 13 +++++---- README.zh-CN.md | 13 +++++---- tests/test_cli_deploy.py | 19 +++++++++--- tests/test_cli_serve.py | 42 +++++++++++++++++++++++++++ tests/test_cli_tui_dispatch.py | 2 +- tests/test_config.py | 24 +++++++-------- tests/test_webui_launcher.py | 22 ++++++++------ 12 files changed, 162 insertions(+), 116 deletions(-) diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index e23d369..6b87afa 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -527,12 +527,9 @@ def _ensure_async_subagent_server(config: Any, *, workspace_dir: str) -> None: console.print(f"[red]{exc}[/red]") raise typer.Exit(1) from exc - # This backend is shared by every UI mode, not just `deploy` / WebUI — so - # the exposure warning belongs here too, or a plain `EvoSci` session would - # put an unauthenticated, shell-capable API on the network with no signal - # at all. Gated on the server actually being up: ensure_langgraph_dev - # fails soft (async falls back to in-process), and warning about a bind - # that never happened would be worse than saying nothing. + # The backend is shared by every UI mode, so the exposure warning lives + # here, not just in deploy/WebUI. Gated on the server being up: warning + # about a bind that never happened would be worse than saying nothing. bind_host = str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip() if ( bind_host @@ -1445,6 +1442,13 @@ def serve( workdir: str | None = typer.Option( None, "--workdir", help="Override workspace directory" ), + host: str | None = typer.Option( + None, + "--host", + help="Interface to bind the langgraph dev backend to (default: " + "langgraph_dev_host = 127.0.0.1). Pass 0.0.0.0 to reach it from " + "another machine — the backend has no auth.", + ), auto_approve: bool = typer.Option( False, "--auto-approve", @@ -1479,6 +1483,9 @@ def serve( from ..config import apply_config_to_env, get_effective_config cli_overrides = {} + # serve starts no front-end, so only the backend bind applies here. + if host is not None and host.strip(): + cli_overrides["langgraph_dev_host"] = host.strip() if auto_approve: cli_overrides["auto_approve"] = True if auto_mode: @@ -2167,12 +2174,11 @@ def _main_callback( host: str | None = typer.Option( None, "--host", - help="Interface to bind servers to (defaults: langgraph_dev_host " - "127.0.0.1, webui_host 0.0.0.0). Sets langgraph_dev_host, which " - "applies in EVERY UI mode — the background langgraph dev backend is " - "shared by tui/cli/webui/serve — and webui_host, which only matters in " - "WebUI mode. Pass 0.0.0.0 to reach both from another machine (the " - "backend has no auth).", + help="Interface to bind servers to (default: 127.0.0.1 for both). " + "Sets langgraph_dev_host — the backend shared by every UI mode — and " + "webui_host (WebUI mode only). Applies to the default entry; the " + "serve and deploy subcommands take their own --host. Pass 0.0.0.0 to " + "reach both from another machine (the backend has no auth).", ), output_format: str | None = typer.Option( None, @@ -2235,10 +2241,8 @@ def _main_callback( if ui: cli_overrides["ui_backend"] = ui if host is not None and host.strip(): - # One flag drives both servers. Note this is NOT WebUI-specific: the - # langgraph dev backend is auto-started for tui/cli/serve too (see - # _ensure_async_subagent_server), so --host narrows or widens the - # agent API in every mode. Only webui_host is WebUI-only. + # One flag drives both servers; the backend applies in EVERY UI mode + # (auto-started for tui/cli/serve too), webui_host only in WebUI mode. cli_overrides["webui_host"] = host.strip() cli_overrides["langgraph_dev_host"] = host.strip() if auto_approve: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index f49d3d6..118a8b7 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -214,15 +214,10 @@ class EvoScientistConfig: langgraph_dev_port: int = 6174 # Network interface the langgraph dev subprocess binds to. Loopback by - # default: this server is the agent API — no authentication, and the - # deployed agent can run shell commands — so it stays off the network until - # asked. Set "0.0.0.0" to reach it from another machine (the WebUI talks to - # it FROM THE BROWSER, and external SDK clients need it too); every launcher - # then prints a red PUBLIC BIND banner while it is exposed. - # - # Callers that *connect* (health probes, async sub-agent self-dispatch) map - # a wildcard bind back to loopback via manager._probe_host, so widening this - # never redirects internal traffic off-box. + # default — this is the unauthenticated agent API (the agent can run + # shell), so "0.0.0.0" is opt-in and every launcher prints a PUBLIC BIND + # banner while exposed. Internal callers *connect* via manager._probe_host, + # so widening never redirects their traffic off-box. langgraph_dev_host: str = "127.0.0.1" # Port for the WebUI front-end (Next.js server from @evoscientist/webui), @@ -231,10 +226,11 @@ class EvoScientistConfig: # its own port (langgraph_dev_port); this is just the browser server. webui_port: int = 4716 - # Network interface the WebUI front-end binds to. Also all interfaces by - # default; it serves the app shell only and holds no credentials (see - # deploy/webui.py:_scrubbed_env). Set "127.0.0.1" to keep it local-only. - webui_host: str = "0.0.0.0" + # Network interface the WebUI front-end binds to. Loopback by default, + # matching langgraph_dev_host: this server is not a passive app shell — + # its API reads, writes and uploads workspace files and installs skills, + # all unauthenticated. Set "0.0.0.0" (with langgraph_dev_host) for LAN. + webui_host: str = "127.0.0.1" # --- Scheduled tasks (cron) --- # Master switch for scheduled tasks (/schedule, NL tools, scheduler context). Defaults @@ -511,7 +507,7 @@ class EvoScientistConfig: # startup. Normalize to the field's own default instead. for _host_field, _host_default in ( ("langgraph_dev_host", "127.0.0.1"), - ("webui_host", "0.0.0.0"), + ("webui_host", "127.0.0.1"), ): _host = getattr(self, _host_field, _host_default) _host = _host.strip() if isinstance(_host, str) else "" diff --git a/EvoScientist/deploy/server.py b/EvoScientist/deploy/server.py index 8a184d5..6976db6 100644 --- a/EvoScientist/deploy/server.py +++ b/EvoScientist/deploy/server.py @@ -124,20 +124,16 @@ def deploy( ) raise typer.Exit(1) - # Same explicit-None resolution for the bind interface. Both branches strip - # (matching run_webui): whitespace reaching socket.bind() surfaces as an - # opaque gaierror, and an all-whitespace value collapses to the default - # rather than erroring. The config branch needs it too even though - # ``EvoScientistConfig.__post_init__`` normalizes these fields — this - # function reads via ``getattr`` and is routinely handed duck-typed config - # objects, which never run that normalization. + # A blank ``--host`` means "not passed" (matching serve), so it can never + # discard the configured bind. Both branches strip: whitespace reaching + # socket.bind() surfaces as an opaque gaierror, and duck-typed configs + # handed to this function never ran ``__post_init__`` normalization. + cli_host = host.strip() if host is not None else "" effective_host = ( - str( - getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST - ).strip() - if host is None - else host.strip() - ) or _DEFAULT_HOST + cli_host + or str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip() + or _DEFAULT_HOST + ) # 4. Pre-flight port check — refuse to start if a non-EvoSci process is # holding the port. If an existing EvoSci langgraph dev is already up, diff --git a/EvoScientist/deploy/webui.py b/EvoScientist/deploy/webui.py index 4a543f2..eec85df 100644 --- a/EvoScientist/deploy/webui.py +++ b/EvoScientist/deploy/webui.py @@ -42,7 +42,7 @@ from ..stream.console import console # Front-end npm package + spec. ``@latest`` → always the newest published UI. _WEBUI_PACKAGE = "@evoscientist/webui@latest" _DEFAULT_WEBUI_PORT = 4716 -_DEFAULT_WEBUI_HOST = "0.0.0.0" +_DEFAULT_WEBUI_HOST = "127.0.0.1" def run_webui(config: Any, workspace_dir: str | None = None) -> None: @@ -89,9 +89,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: # webui_port = the local Next.js server the browser actually opens. backend_port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT)) webui_port = int(getattr(config, "webui_port", _DEFAULT_WEBUI_PORT)) - # ...and their bind interfaces. The defaults deliberately differ: the - # front-end is exposed (it serves the app shell and holds no credentials), - # the backend is not (unauthenticated API, agent can run shell). + # ...and their bind interfaces, both loopback by default — the front-end + # carries workspace/skill APIs of its own (see config.webui_host). backend_host = ( str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST) ).strip() or _DEFAULT_HOST @@ -213,10 +212,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: # lets the UI's config prefill point at our backend automatically. Secrets # are scrubbed — the browser UI never needs LLM provider API keys. # - # HOSTNAME is the front-end's bind knob: the package ships no --host flag, - # and its bin launcher passes `HOSTNAME: process.env.HOSTNAME || "127.0.0.1"` - # through to the Next standalone server. Setting it here is therefore the - # only supported way to widen the front-end's interface. + # HOSTNAME is the front-end's only bind knob: the package has no --host + # flag; its launcher forwards `HOSTNAME || "127.0.0.1"` to the Next server. webui_env = _scrubbed_env( { "EVOSCIENTIST_LANGGRAPH_DEV_PORT": str(backend_port), @@ -224,10 +221,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: "HOSTNAME": webui_host, } ) - # The UI connects to the backend from the BROWSER, not server-side, so a - # remote visitor needs a backend address that resolves on *their* machine. - # Spell that out when the front-end is exposed but the backend isn't — - # otherwise the page loads and every request silently fails. + # The UI reaches the backend from the BROWSER; when only the front-end is + # exposed, remote pages load but every request fails — say so. remote_backend_hint = "" if not _is_loopback_host(webui_host) and _is_loopback_host(backend_host): remote_backend_hint = ( @@ -260,6 +255,13 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: f"[bold red]Backend listening on {backend_host} — no auth, and the " f"agent can run shell. Trusted networks only.[/bold red]" ) + if not _is_loopback_host(webui_host): + console.print( + "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] " + f"[bold red]WebUI listening on {webui_host} — its API reads, writes " + f"and uploads workspace files and installs skills, with no auth. " + f"Trusted networks only.[/bold red]" + ) popen_kwargs: dict[str, Any] = {"env": webui_env} if os.name == "posix": diff --git a/EvoScientist/langgraph_dev/manager.py b/EvoScientist/langgraph_dev/manager.py index de01e4b..6eec613 100644 --- a/EvoScientist/langgraph_dev/manager.py +++ b/EvoScientist/langgraph_dev/manager.py @@ -116,11 +116,9 @@ _LOCK = threading.RLock() # corresponding url= field on AsyncSubAgent specs. _DEFAULT_PORT = 6174 -# Default bind interface — loopback, matching ``config.langgraph_dev_host`` so -# there is a single story about where this server listens. SECURITY: the -# langgraph dev server is the unauthenticated agent API; widening it with -# ``config.langgraph_dev_host = "0.0.0.0"`` puts it on the network, and every -# launcher warns while it is exposed. +# Default bind interface — loopback, matching ``config.langgraph_dev_host``. +# SECURITY: this is the unauthenticated agent API; launchers print a PUBLIC +# BIND banner while it is exposed. _DEFAULT_HOST = "127.0.0.1" # Wildcard bind addresses: the server listens on every interface, but you @@ -132,12 +130,8 @@ _WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""}) def _probe_host(host: str = _DEFAULT_HOST) -> str: """Map a bind address to one a client can actually connect to. - The distinction that makes host support tractable: only ``bind()`` needs - the configured interface. Every consumer in this module that *connects* — - health probes, occupancy checks, async sub-agent self-dispatch — wants a - reachable address. Binding ``0.0.0.0`` includes loopback, so ``127.0.0.1`` - stays correct there; a specific IP is returned as-is because loopback - would not reach a server bound only to that interface. + A wildcard bind includes loopback, so clients use ``127.0.0.1``; a + specific interface is returned as-is — loopback would not reach it. """ return "127.0.0.1" if host in _WILDCARD_HOSTS else host @@ -145,9 +139,8 @@ def _probe_host(host: str = _DEFAULT_HOST) -> str: def _is_loopback_host(host: str) -> bool: """Return True if binding ``host`` keeps the server unreachable off-box. - Drives the "public bind" security warning in the deploy / WebUI launchers, - so it must be conservative: anything not provably loopback (a wildcard, a - LAN address, an unresolvable name) counts as exposed and gets the banner. + Drives the PUBLIC BIND warning, so it is conservative: anything not + provably loopback counts as exposed. """ return host.strip().lower() in {"127.0.0.1", "::1", "localhost"} @@ -425,11 +418,9 @@ def _can_bind_port(port: int, host: str = _DEFAULT_HOST) -> bool: actually attempts the bind that langgraph dev would attempt, then closes immediately. - Binds the *literal* ``host`` — not ``_probe_host(host)`` — precisely - because this must replicate the server's own bind. Probing loopback - while the server will claim ``0.0.0.0`` gives false confidence: another - process holding a single non-loopback interface would let our probe - succeed and the real bind fail. + Binds the *literal* ``host`` — not ``_probe_host(host)`` — because this + must replicate the server's own bind: a loopback probe can succeed while + the real wildcard bind still fails on another interface's conflict. """ import socket as _socket @@ -613,9 +604,9 @@ def start_langgraph_dev( (``CustomSandboxBackend`` derives its workspace root from cwd via ``paths.WORKSPACE_ROOT``). Defaults to ``Path.cwd()``. port: TCP port to bind. Defaults to 6174 (Kaprekar's constant). - host: Network interface to bind. Defaults to all interfaces. SECURITY: - this exposes an unauthenticated API whose agent can run shell - commands — pass ``127.0.0.1`` on untrusted networks. + host: Network interface to bind. Defaults to loopback. SECURITY: + widening this exposes an unauthenticated API whose agent can run + shell commands — only pass ``0.0.0.0`` on trusted networks. file_persistence: When True (default), langgraph dev writes its full ``.langgraph_api/`` cache so async-task / Store / scheduler state survives subprocess restarts. Set False to suppress periodic diff --git a/README.md b/README.md index 4479a78..23b3164 100644 --- a/README.md +++ b/README.md @@ -454,20 +454,21 @@ EvoSci config set webui_port 4800 # change the front-end port (must differ fr Requires **Node.js 24 LTS** (for `npx`); the first launch downloads `@evoscientist/webui` and needs network. Note: the WebUI does not show your CLI/TUI chat history, and `-p` / `--resume` fall back to the classic CLI. -**Opening it from another machine.** The front-end listens on `0.0.0.0` by default, so `http://:4716` works over the LAN out of the box. The backend stays on loopback (`127.0.0.1`), and the UI connects to it **from the browser** — so widen it too, then point the UI's deployment URL at `http://:6174` rather than leaving it on localhost: +**Opening it from another machine.** Both servers bind loopback (`127.0.0.1`) by default, so the WebUI is local-only out of the box. To use it over the LAN, widen both — the UI connects to the backend **from the browser**, so also point the UI's deployment URL at `http://:6174` rather than leaving it on localhost: ```bash EvoSci --host 0.0.0.0 # this session only, both servers -EvoSci config set langgraph_dev_host 0.0.0.0 # persist, backend only -EvoSci config set webui_host 127.0.0.1 # persist, front-end only +EvoSci config set webui_host 0.0.0.0 # persist, front-end (port 4716) +EvoSci config set langgraph_dev_host 0.0.0.0 # persist, backend (port 6174) ``` -`EvoSci deploy` takes the same flag: `EvoSci deploy --host 0.0.0.0`. +`EvoSci deploy` and `EvoSci serve` take the same flag: `EvoSci deploy --host 0.0.0.0`. -> [!WARNING] +> 🚨 **WARNING** +> > The backend is an **unauthenticated API whose agent can run shell commands**. Anyone who can reach port 6174 controls it, so only widen it on a trusted network — EvoSci prints a red `⚠ PUBLIC BIND` banner at every startup while it's exposed. > -> **This is not WebUI-specific.** The langgraph dev backend is auto-started for `tui`, `cli`, `serve` and `deploy` too, so `--host` puts port 6174 on the network in every mode. Only the front-end port (4716) is WebUI-only. +> **This is not WebUI-specific.** The langgraph dev backend is auto-started for `tui`, `cli`, `serve` and `deploy` too, so `--host` puts port 6174 on the network in every mode. The front-end port (4716) is WebUI-only but not harmless either: its API reads, writes and uploads workspace files and installs skills, so it gets the same banner and the same trusted-network rule. > > On an untrusted network, leave the backend on loopback and reach it over SSH instead: `ssh -L 6174:localhost:6174 -L 4716:localhost:4716 `. diff --git a/README.zh-CN.md b/README.zh-CN.md index 41bf815..eb5e699 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -460,20 +460,21 @@ EvoSci config set webui_port 4800 # 修改前端端口(须与 langgraph dev 需要 **Node.js 24 LTS**(提供 `npx`);首次启动会下载 `@evoscientist/webui`,需要联网。注意:WebUI 不会显示 CLI/TUI 的历史会话,且 `-p` / `--resume` 会回退到经典 CLI。 -**从其他机器访问。** 前端默认监听 `0.0.0.0`,因此开箱即可通过 `http://<本机IP>:4716` 从局域网打开。后端默认仍留在回环地址(`127.0.0.1`),而 UI 是**从浏览器**直连后端的,所以还需把后端一并放开,并把 UI 里的部署地址填成 `http://<本机IP>:6174`,而不是保留 localhost: +**从其他机器访问。** 两个服务默认都绑定回环地址(`127.0.0.1`),WebUI 开箱即为仅本机可用。要在局域网使用,需把两者一并放开——UI 是**从浏览器**直连后端的,因此还要把 UI 里的部署地址填成 `http://<本机IP>:6174`,而不是保留 localhost: ```bash EvoSci --host 0.0.0.0 # 仅本次会话,两个服务一起 -EvoSci config set langgraph_dev_host 0.0.0.0 # 持久化,仅后端 -EvoSci config set webui_host 127.0.0.1 # 持久化,仅前端 +EvoSci config set webui_host 0.0.0.0 # 持久化,前端(4716 端口) +EvoSci config set langgraph_dev_host 0.0.0.0 # 持久化,后端(6174 端口) ``` -`EvoSci deploy` 也支持同名参数:`EvoSci deploy --host 0.0.0.0`。 +`EvoSci deploy` 与 `EvoSci serve` 也支持同名参数:`EvoSci deploy --host 0.0.0.0`。 -> [!WARNING] +> 🚨 **警告** +> > 后端是**无鉴权、且 agent 能执行 shell 的 API**。任何能访问 6174 端口的人都能完全控制它,因此只应在可信网络里放开;暴露期间 EvoSci 每次启动都会打印红色 `⚠ PUBLIC BIND` 横幅。 > -> **这不是 WebUI 独有的问题。** `tui`、`cli`、`serve`、`deploy` 都会自动启动同一个 langgraph dev 后端,因此 `--host` 会让 6174 端口在所有模式下都暴露到网络上;只有前端端口 4716 是 WebUI 专属的。 +> **这不是 WebUI 独有的问题。** `tui`、`cli`、`serve`、`deploy` 都会自动启动同一个 langgraph dev 后端,因此 `--host` 会让 6174 端口在所有模式下都暴露到网络上。前端端口 4716 虽然只在 WebUI 模式下存在,但同样不是无害的:它的 API 可以读写、上传工作区文件并安装 skill,因此同样会打印横幅、同样只应在可信网络放开。 > > 网络不可信时,请让后端留在回环地址,改用 SSH 隧道访问:`ssh -L 6174:localhost:6174 -L 4716:localhost:4716 <主机>`。 diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index a596c5d..db1bf36 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -346,10 +346,21 @@ def test_deploy_host_falls_back_when_config_lacks_field(monkeypatch, tmp_path): assert captured["host_passed"] == "127.0.0.1" -def test_deploy_host_whitespace_collapses_to_default(monkeypatch, tmp_path): - """``--host " "`` would reach socket.bind() as an empty string and raise - an opaque gaierror; it must degrade to the default instead.""" - config = _make_config(default_workdir=str(tmp_path)) +def test_deploy_blank_host_keeps_config_value(monkeypatch, tmp_path): + """``--host " "`` means "not passed" (as in serve), so it must not discard + the configured bind.""" + config = _make_config( + default_workdir=str(tmp_path), langgraph_dev_host="192.168.1.5" + ) + captured = _run_deploy_once(monkeypatch, config, host=" ") + + assert captured["host_passed"] == "192.168.1.5" + + +def test_deploy_blank_host_and_blank_config_use_default(monkeypatch, tmp_path): + """Whitespace on both sides would reach socket.bind() as an empty string + and raise an opaque gaierror; it degrades to the default instead.""" + config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host=" ") captured = _run_deploy_once(monkeypatch, config, host=" ") assert captured["host_passed"] == "127.0.0.1" diff --git a/tests/test_cli_serve.py b/tests/test_cli_serve.py index 21dd646..7cc367f 100644 --- a/tests/test_cli_serve.py +++ b/tests/test_cli_serve.py @@ -51,6 +51,7 @@ def _run_serve_once( config, *, workdir: str | None = None, + host: str | None = None, no_thinking: bool = False, debug: bool = False, cwd: str | None = None, @@ -93,8 +94,14 @@ def _run_serve_once( def get(self, timeout=None): raise KeyboardInterrupt() + def _fake_ensure_async_server(cfg, *, workspace_dir): + captured["ensure_config"] = cfg + monkeypatch.setattr(commands, "set_workspace_root", _fake_set_workspace_root) monkeypatch.setattr(commands, "ensure_dirs", _fake_ensure_dirs) + monkeypatch.setattr( + commands, "_ensure_async_subagent_server", _fake_ensure_async_server + ) monkeypatch.setattr(commands, "_load_agent", _fake_load_agent) monkeypatch.setattr( commands, "_start_channels_bus_mode", _fake_start_channels_bus_mode @@ -126,6 +133,7 @@ def _run_serve_once( object(), no_thinking=no_thinking, workdir=workdir, + host=host, debug=debug, auto_approve=auto_approve, auto_mode=auto_mode, @@ -236,6 +244,40 @@ def test_serve_debug_sets_log_level_and_channel_trace(monkeypatch, tmp_path): assert configure_calls == [("DEBUG", "true")] +def test_serve_host_flag_narrows_or_widens_backend_bind(monkeypatch, tmp_path): + """`EvoSci serve --host` must reach langgraph_dev_host — the flag was + silently dropped before, leaving the backend bind config-only.""" + ws = str((tmp_path / "ws").resolve()) + config = _make_config(default_workdir=ws) + + _, captured = _run_serve_once(monkeypatch, config, workdir=ws, host="0.0.0.0") + + assert captured["cli_overrides"] == {"langgraph_dev_host": "0.0.0.0"} + # The effective config carrying the override must be the one handed to the + # backend launcher — not just recorded in the overrides dict. + assert captured["ensure_config"].langgraph_dev_host == "0.0.0.0" + + +def test_serve_host_flag_is_stripped(monkeypatch, tmp_path): + ws = str((tmp_path / "ws").resolve()) + config = _make_config(default_workdir=ws) + + _, captured = _run_serve_once( + monkeypatch, config, workdir=ws, host=" 192.168.1.5 " + ) + + assert captured["cli_overrides"] == {"langgraph_dev_host": "192.168.1.5"} + + +def test_serve_blank_host_flag_leaves_config_defaults(monkeypatch, tmp_path): + ws = str((tmp_path / "ws").resolve()) + config = _make_config(default_workdir=ws) + + _, captured = _run_serve_once(monkeypatch, config, workdir=ws, host=" ") + + assert captured["cli_overrides"] == {} + + def test_serve_auto_approve_only_sets_auto_approve(monkeypatch, tmp_path): ws = str((tmp_path / "ws").resolve()) config = _make_config(default_workdir=ws, enable_ask_user=True) diff --git a/tests/test_cli_tui_dispatch.py b/tests/test_cli_tui_dispatch.py index 9b5405d..0235b09 100644 --- a/tests/test_cli_tui_dispatch.py +++ b/tests/test_cli_tui_dispatch.py @@ -123,7 +123,7 @@ def test_no_host_flag_leaves_config_defaults(monkeypatch): assert result.exit_code == 0 assert "webui_host" not in calls["overrides"] - assert calls["webui_config"].webui_host == "0.0.0.0" + assert calls["webui_config"].webui_host == "127.0.0.1" def _run_ensure_backend(monkeypatch, config, *, server_up=True): diff --git a/tests/test_config.py b/tests/test_config.py index a7897b6..91357e0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -154,24 +154,22 @@ class TestEvoScientistConfig: assert config.imessage_enabled is False assert config.imessage_allowed_senders == "" - def test_bind_host_defaults_differ_per_server(self): - """The front-end binds every interface out of the box; the backend does - not. + def test_bind_hosts_default_to_loopback(self): + """Both servers stay off the network until asked. The backend is an unauthenticated API whose agent can run shell - commands, so it stays on loopback until asked — ``langgraph_dev_host = - 0.0.0.0`` opts in, and the launchers then print a red PUBLIC BIND - banner while it is exposed. The WebUI front-end serves the app shell - only and holds no credentials, so it defaults to the wildcard. + commands; the front-end serves the workspace file/upload and + skill-install endpoints. Neither is a safe default to publish, so + ``--host 0.0.0.0`` / ``config set`` opts in to LAN exposure. """ config = EvoScientistConfig() assert config.langgraph_dev_host == "127.0.0.1" - assert config.webui_host == "0.0.0.0" + assert config.webui_host == "127.0.0.1" @pytest.mark.parametrize( ("field", "default"), - [("langgraph_dev_host", "127.0.0.1"), ("webui_host", "0.0.0.0")], + [("langgraph_dev_host", "127.0.0.1"), ("webui_host", "127.0.0.1")], ) @pytest.mark.parametrize("blank", ["", " ", "\t"]) def test_blank_bind_host_falls_back_to_default(self, field, blank, default): @@ -182,11 +180,11 @@ class TestEvoScientistConfig: @pytest.mark.parametrize( ("field", "value"), - [("langgraph_dev_host", "0.0.0.0"), ("webui_host", "127.0.0.1")], + [("langgraph_dev_host", "0.0.0.0"), ("webui_host", "0.0.0.0")], ) def test_bind_host_opt_out_is_preserved(self, field, value): - """Each field's escape hatch — widen the backend, narrow the front-end — - must survive normalization untouched.""" + """The LAN escape hatch — widening either bind to the wildcard — must + survive normalization untouched.""" config = EvoScientistConfig(**{field: value}) assert getattr(config, field) == value @@ -1026,7 +1024,7 @@ class TestDotenvIsolation: ("field", "env_var", "value"), [ ("langgraph_dev_host", "EVOSCIENTIST_LANGGRAPH_DEV_HOST", "0.0.0.0"), - ("webui_host", "EVOSCIENTIST_WEBUI_HOST", "127.0.0.1"), + ("webui_host", "EVOSCIENTIST_WEBUI_HOST", "0.0.0.0"), ], ) def test_bind_hosts_are_env_overridable( diff --git a/tests/test_webui_launcher.py b/tests/test_webui_launcher.py index 63f42e2..602888f 100644 --- a/tests/test_webui_launcher.py +++ b/tests/test_webui_launcher.py @@ -25,7 +25,7 @@ def _make_config( langgraph_dev_port: int = 6174, langgraph_dev_host: str = "127.0.0.1", webui_port: int = 4716, - webui_host: str = "0.0.0.0", + webui_host: str = "127.0.0.1", ): return SimpleNamespace( default_workdir=default_workdir, @@ -169,8 +169,10 @@ def test_hostname_env_carries_webui_host(monkeypatch): ) -def test_hostname_env_honors_loopback_opt_out(monkeypatch): - config = _make_config(webui_host="127.0.0.1") +def test_hostname_env_defaults_to_loopback(monkeypatch): + """The front-end serves the workspace file/upload and skill-install + endpoints, so it stays off the network until ``webui_host`` opts in.""" + config = _make_config() captured = _run_webui_once(monkeypatch, config) assert captured["npx_env"].get("HOSTNAME") == "127.0.0.1" @@ -195,11 +197,11 @@ def test_no_host_flag_passed_to_npx(monkeypatch): @pytest.mark.parametrize("blank", ["", " "]) -def test_blank_webui_host_falls_back_to_wildcard(monkeypatch, blank): +def test_blank_webui_host_falls_back_to_loopback(monkeypatch, blank): config = _make_config(webui_host=blank) captured = _run_webui_once(monkeypatch, config) - assert captured["npx_env"].get("HOSTNAME") == "0.0.0.0" + assert captured["npx_env"].get("HOSTNAME") == "127.0.0.1" # ============================================================================= @@ -242,13 +244,15 @@ def test_no_public_bind_warning_when_backend_on_loopback(monkeypatch): assert not any("PUBLIC BIND" in line for line in captured["printed"]) -def test_webui_host_alone_does_not_trigger_warning(monkeypatch): - """Only the backend earns a banner. The front-end serves the app shell and - holds no credentials, so warning on it would double the noise.""" +def test_public_bind_warning_when_frontend_exposed(monkeypatch): + """The front-end earns its own banner: it is not a passive app shell — its + API reads, writes and uploads workspace files and installs skills.""" config = _make_config(webui_host="0.0.0.0", langgraph_dev_host="127.0.0.1") captured = _run_webui_once(monkeypatch, config) - assert not any("PUBLIC BIND" in line for line in captured["printed"]) + banner = "\n".join(captured["printed"]) + assert "WebUI listening on 0.0.0.0" in banner + assert "Backend listening" not in banner def test_remote_backend_hint_when_frontend_exposed_but_backend_is_not(monkeypatch):