diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1cb59a3..1f12c4a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,9 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v6 + # Full tag required — setup-uv dropped major/minor tags in v8.0.0, so + # `@v9` does not resolve. See the note in lint.yml. + - uses: astral-sh/setup-uv@v9.0.0 with: python-version: "3.11" cache-dependency-glob: "**/pyproject.toml" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8678300..a5c5ccb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,11 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v6 + # Pinned to a full tag on purpose: setup-uv stopped publishing major / + # minor tags in v8.0.0 (supply-chain hardening), so `@v9` does not exist + # and would fail the job. Releases are immutable, so the tag is as safe + # as a SHA. v7 is where the action moved off the deprecated node20. + - uses: astral-sh/setup-uv@v9.0.0 with: python-version: "3.11" cache-dependency-glob: "**/pyproject.toml" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9bbb96f..9e04633 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v6 + # Full tag required — setup-uv dropped major/minor tags in v8.0.0, so + # `@v9` does not resolve. See the note in lint.yml. + - uses: astral-sh/setup-uv@v9.0.0 with: python-version: ${{ matrix.python-version }} cache-dependency-glob: "**/pyproject.toml" diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index d4980b2..6aab0d6 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -467,7 +467,12 @@ def _maybe_swap_async_subagents( from deepagents import AsyncSubAgent - port = int(getattr(cfg, "langgraph_dev_port", 6174)) + from .langgraph_dev.sdk import langgraph_dev_url + + # Self-dispatch target. Resolved through ``langgraph_dev_url`` so it tracks + # both ``langgraph_dev_port`` and ``langgraph_dev_host`` — a wildcard bind + # maps back to loopback, a pinned interface is honored verbatim. + dev_url = langgraph_dev_url(cfg) out = [] agent_specs: dict[str, AsyncSubAgent] = {} # MCP tools routed to async sub-agents (via ``expose_to: `` in @@ -482,7 +487,7 @@ def _maybe_swap_async_subagents( name=name, description=async_specs[name], graph_id=name, - url=f"http://localhost:{port}", + url=dev_url, ) agent_specs[name] = spec out.append(spec) diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 9b6cf12..fd9b3c4 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -503,7 +503,13 @@ def _ensure_async_subagent_server(config: Any, *, workspace_dir: str) -> None: state would route async sub-agent calls to a process pinned to /A while the main agent runs in /B. """ - from ..langgraph_dev.manager import WorkspaceMismatchError, ensure_langgraph_dev + from ..langgraph_dev.manager import ( + _DEFAULT_HOST, + WorkspaceMismatchError, + _is_loopback_host, + ensure_langgraph_dev, + is_async_subagents_available, + ) try: with console.status( @@ -516,6 +522,25 @@ 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. + bind_host = str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip() + if ( + bind_host + and not _is_loopback_host(bind_host) + and is_async_subagents_available() + ): + console.print( + "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] " + f"[bold red]Agent server listening on {bind_host} — no auth, and " + f"the agent can run shell. Use --host 127.0.0.1 on untrusted " + f"networks.[/bold red]" + ) + def _reconcile_autoskill_schedule(config: Any, *, workspace_dir: str) -> None: """Best-effort reconciliation for EvoMemory's hidden AutoSkills cron.""" @@ -2130,6 +2155,16 @@ def _main_callback( "--ui", help="UI backend: tui (default), cli, or webui.", ), + 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).", + ), output_format: str | None = typer.Option( None, "--output-format", @@ -2190,6 +2225,13 @@ def _main_callback( cli_overrides["show_thinking"] = False 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. + cli_overrides["webui_host"] = host.strip() + cli_overrides["langgraph_dev_host"] = host.strip() if auto_approve: cli_overrides["auto_approve"] = True if effective_auto_mode: diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py index ac3f164..0228ae6 100644 --- a/EvoScientist/config/onboard/steps.py +++ b/EvoScientist/config/onboard/steps.py @@ -156,8 +156,14 @@ def _step_langgraph_dev_port(config: EvoScientistConfig) -> int: f"EvoSci config set langgraph_dev_port [/yellow]" ) else: + # Render the address the configured bind actually produces rather than + # a hard-coded loopback URL — the two diverge once langgraph_dev_host + # is pinned to a specific interface. + from ...langgraph_dev.manager import _base_url + + host = getattr(config, "langgraph_dev_host", "") console.print( - f" [green]✓ EvoScientist will run on http://127.0.0.1:{port}[/green]" + f" [green]✓ EvoScientist will run on {_base_url(port, host)}[/green]" ) return port @@ -220,7 +226,15 @@ def _step_webui_port(config: EvoScientistConfig) -> int: raise KeyboardInterrupt() port = int(raw) if raw else current_port - console.print(f" [green]✓ WebUI will open at http://localhost:{port}[/green]") + # Same reasoning as the langgraph-dev step: render the configured bind, not + # a hard-coded localhost. A wildcard bind still shows loopback here — that + # is the address this machine's own browser opens. + from ...langgraph_dev.manager import _format_hostport + + host = getattr(config, "webui_host", "") + console.print( + f" [green]✓ WebUI will open at http://{_format_hostport(host, port)}[/green]" + ) console.print( " [yellow]⚠️ Note: the WebUI won't show your CLI/TUI chat history " "yet.[/yellow]" diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 5ff3c5b..f49d3d6 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -213,12 +213,29 @@ class EvoScientistConfig: # 2024. Override if it conflicts with another local service. 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. + langgraph_dev_host: str = "127.0.0.1" + # Port for the WebUI front-end (Next.js server from @evoscientist/webui), # used only when ui_backend == "webui". 4716 is 6174 reversed — a memorable # pairing with the langgraph dev port that it connects to. The backend keeps # 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" + # --- Scheduled tasks (cron) --- # Master switch for scheduled tasks (/schedule, NL tools, scheduler context). Defaults # True so the feature is available out-of-the-box; set False to disable. @@ -489,6 +506,17 @@ class EvoScientistConfig: _normalize_str_enum_fields(self) + # Bind hosts reach socket.bind() / the langgraph CLI verbatim, where a + # stray-whitespace or empty value surfaces as an opaque gaierror at + # 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"), + ): + _host = getattr(self, _host_field, _host_default) + _host = _host.strip() if isinstance(_host, str) else "" + setattr(self, _host_field, _host or _host_default) + synthesis_time = _normalize_hhmm(self.memory_skill_synthesis_time) if synthesis_time is None: logging.getLogger(__name__).warning( @@ -802,7 +830,9 @@ _ENV_MAPPINGS = { "checkpoint_keep_per_thread": "EVOSCIENTIST_CHECKPOINT_KEEP_PER_THREAD", "enable_async_subagents": "EVOSCIENTIST_ENABLE_ASYNC_SUBAGENTS", "langgraph_dev_port": "EVOSCIENTIST_LANGGRAPH_DEV_PORT", + "langgraph_dev_host": "EVOSCIENTIST_LANGGRAPH_DEV_HOST", "webui_port": "EVOSCIENTIST_WEBUI_PORT", + "webui_host": "EVOSCIENTIST_WEBUI_HOST", "enable_scheduler": "EVOSCIENTIST_ENABLE_SCHEDULER", "scheduler_default_timezone": "EVOSCIENTIST_SCHEDULER_DEFAULT_TIMEZONE", "code_interpreter_timeout": "EVOSCIENTIST_CODE_INTERPRETER_TIMEOUT", diff --git a/EvoScientist/deploy/server.py b/EvoScientist/deploy/server.py index 2af9a0b..8a184d5 100644 --- a/EvoScientist/deploy/server.py +++ b/EvoScientist/deploy/server.py @@ -46,6 +46,13 @@ def deploy( "--port", help="Port for langgraph dev (default: config.langgraph_dev_port = 6174)", ), + host: str | None = typer.Option( + None, + "--host", + help="Interface to bind (default: config.langgraph_dev_host = " + "127.0.0.1, i.e. this machine only — pass 0.0.0.0 to reach it from " + "other machines, but note the server has no auth)", + ), tunnel: bool = typer.Option( False, "--tunnel", @@ -66,8 +73,11 @@ def deploy( """ from ..config import apply_config_to_env, get_effective_config from ..langgraph_dev.manager import ( + _DEFAULT_HOST, _DEFAULT_PORT, RUNTIME, + _base_url, + _is_loopback_host, _is_port_occupied, is_langgraph_dev_running, read_tunnel_url, @@ -114,12 +124,27 @@ 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. + effective_host = ( + str( + getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST + ).strip() + if host is None + else host.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, # also refuse (deploy is the "primary server" — running multiple on the # same port is a configuration error). - if _is_port_occupied(effective_port): - if is_langgraph_dev_running(port=effective_port): + if _is_port_occupied(effective_port, effective_host): + if is_langgraph_dev_running(port=effective_port, host=effective_host): console.print( f"[red]Port {effective_port} is already serving a langgraph dev " f"instance.[/red]" @@ -144,6 +169,7 @@ def deploy( Panel( Text.from_markup( f"[bold]Workspace:[/bold] {_shorten(ws)}\n" + f"[bold]Host:[/bold] {effective_host}\n" f"[bold]Port:[/bold] {effective_port}\n" f"[bold]Auth:[/bold] {_auth_label}" ), @@ -162,6 +188,13 @@ def deploy( f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]" ) + if not _is_loopback_host(effective_host): + console.print( + "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] " + f"[bold red]Listening on {effective_host} — no auth, and the agent " + f"can run shell. Trusted networks only.[/bold red]" + ) + if tunnel: console.print( "[bold white on red] ⚠ PUBLIC TUNNEL [/bold white on red] " @@ -197,6 +230,7 @@ def deploy( proc = start_langgraph_dev( workspace_dir=Path(ws), port=effective_port, + host=effective_host, file_persistence=file_persistence, jobs_per_worker=jobs_per_worker, deploy_mode=True, @@ -236,7 +270,7 @@ def deploy( Panel( Text.from_markup( f"[bold]Endpoint:[/bold] " - f"http://localhost:{effective_port}\n" + f"{_base_url(effective_port, effective_host)}\n" f"{public_line}" f"[bold]Assistant ID:[/bold] EvoScientist\n" f"[bold]Connect via:[/bold] any LangChain SDK / " diff --git a/EvoScientist/deploy/webui.py b/EvoScientist/deploy/webui.py index cd44fd1..4a543f2 100644 --- a/EvoScientist/deploy/webui.py +++ b/EvoScientist/deploy/webui.py @@ -42,6 +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" def run_webui(config: Any, workspace_dir: str | None = None) -> None: @@ -58,8 +59,12 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: """ from ..config import apply_config_to_env from ..langgraph_dev.manager import ( + _DEFAULT_HOST, _DEFAULT_PORT, RUNTIME, + _base_url, + _format_hostport, + _is_loopback_host, _is_port_occupied, _read_workspace_sidecar, is_langgraph_dev_running, @@ -84,6 +89,15 @@ 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). + backend_host = ( + str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST) + ).strip() or _DEFAULT_HOST + webui_host = ( + str(getattr(config, "webui_host", _DEFAULT_WEBUI_HOST) or _DEFAULT_WEBUI_HOST) + ).strip() or _DEFAULT_WEBUI_HOST for label, p in (("langgraph dev", backend_port), ("WebUI", webui_port)): if not (1 <= p <= 65535): console.print( @@ -128,8 +142,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: # else start a fresh deploy-mode one (full MCP + async). Refuse a foreign # occupant — that's a configuration error, not something to silently share. started_proc = None - if _is_port_occupied(backend_port): - if is_langgraph_dev_running(port=backend_port): + if _is_port_occupied(backend_port, backend_host): + if is_langgraph_dev_running(port=backend_port, host=backend_host): # Reuse an existing EvoSci server only when it serves THIS workspace # — mirror the sidecar guard in ensure_langgraph_dev so WebUI started # from workspace B never silently binds to a server pinned to @@ -174,6 +188,7 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: started_proc = start_langgraph_dev( workspace_dir=Path(ws), port=backend_port, + host=backend_host, file_persistence=file_persistence, jobs_per_worker=jobs_per_worker, deploy_mode=True, @@ -184,7 +199,7 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: raise typer.Exit(1) from exc console.print("[green]✓[/green] langgraph dev ready") - if _is_port_occupied(webui_port): + if _is_port_occupied(webui_port, webui_host): console.print( f"[yellow]⚠ Port {webui_port} is already in use; the WebUI server " f"may fail to start. Change it with " @@ -197,20 +212,41 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: # inherited so it all shows in THIS terminal. EVOSCIENTIST_LANGGRAPH_DEV_PORT # 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. webui_env = _scrubbed_env( { "EVOSCIENTIST_LANGGRAPH_DEV_PORT": str(backend_port), "PORT": str(webui_port), + "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. + remote_backend_hint = "" + if not _is_loopback_host(webui_host) and _is_loopback_host(backend_host): + remote_backend_hint = ( + f"\n[yellow]Note:[/yellow] the UI connects to the backend from the " + f"browser. Remote visitors cannot reach a loopback backend — run " + f"[bold]EvoSci config set langgraph_dev_host 0.0.0.0[/bold] and " + f"point the UI at [bold]http://:{backend_port}" + f"[/bold].\n" + ) console.print( Panel( Text.from_markup( - f"[bold]Backend:[/bold] http://localhost:{backend_port} " + f"[bold]Backend:[/bold] {_base_url(backend_port, backend_host)} " f"[dim](langgraph dev — Assistant: EvoScientist)[/dim]\n" - f"[bold]WebUI:[/bold] http://localhost:{webui_port} " + f"[bold]WebUI:[/bold] " + f"http://{_format_hostport(webui_host, webui_port)} " f"[dim](opens in your browser)[/dim]\n" - f"[bold]Logs:[/bold] {_shorten(str(RUNTIME.log_file))}\n\n" + f"[bold]Logs:[/bold] {_shorten(str(RUNTIME.log_file))}\n" + f"{remote_backend_hint}\n" f"[dim]Fetching {_WEBUI_PACKAGE} via npx (first run may take a " f"moment)… Press Ctrl+C to stop.[/dim]" ), @@ -218,6 +254,12 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: border_style="green", ) ) + if not _is_loopback_host(backend_host): + console.print( + "[bold white on red] ⚠ PUBLIC BIND [/bold white on red] " + f"[bold red]Backend listening on {backend_host} — no auth, and the " + f"agent can run shell. 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 42bcc57..de01e4b 100644 --- a/EvoScientist/langgraph_dev/manager.py +++ b/EvoScientist/langgraph_dev/manager.py @@ -116,9 +116,50 @@ _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_HOST = "127.0.0.1" -def _base_url(port: int = _DEFAULT_PORT) -> str: - return f"http://localhost:{port}" +# Wildcard bind addresses: the server listens on every interface, but you +# cannot meaningfully *connect* to them (0.0.0.0 is routed to loopback on +# Linux and outright rejected on Windows), so clients target loopback instead. +_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. + """ + return "127.0.0.1" if host in _WILDCARD_HOSTS else host + + +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. + """ + return host.strip().lower() in {"127.0.0.1", "::1", "localhost"} + + +def _format_hostport(host: str, port: int) -> str: + """Render ``host:port`` for a URL, bracketing IPv6 literals per RFC 3986.""" + probe = _probe_host(host) + return f"[{probe}]:{port}" if ":" in probe else f"{probe}:{port}" + + +def _base_url(port: int = _DEFAULT_PORT, host: str = _DEFAULT_HOST) -> str: + return f"http://{_format_hostport(host, port)}" # Default rollover threshold for ``RUNTIME.log_file`` — once the log @@ -331,32 +372,37 @@ def is_langgraph_dev_running( base_url: str | None = None, *, port: int = _DEFAULT_PORT, + host: str = _DEFAULT_HOST, ) -> bool: """Check whether a langgraph dev API is already serving at ``base_url``. - ``base_url`` overrides ``port`` when given. + ``base_url`` overrides ``port``/``host`` when given. """ - url = base_url or _base_url(port) + url = base_url or _base_url(port, host) try: return httpx.get(f"{url}/ok", timeout=1.0).status_code == 200 except (httpx.TransportError, OSError): return False -def _is_port_occupied(port: int) -> bool: - """Return True if anything is listening on ``port`` (TCP, IPv4).""" +def _is_port_occupied(port: int, host: str = _DEFAULT_HOST) -> bool: + """Return True if anything is listening on ``host:port`` (TCP).""" import socket as _socket - s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + probe = _probe_host(host) + family = _socket.AF_INET6 if ":" in probe else _socket.AF_INET + s = _socket.socket(family, _socket.SOCK_STREAM) try: s.settimeout(0.5) # connect_ex returns 0 on success (something accepted), nonzero otherwise - return s.connect_ex(("127.0.0.1", port)) == 0 + return s.connect_ex((probe, port)) == 0 finally: s.close() -def _wait_for_port_release(port: int, timeout: float = 10.0) -> bool: +def _wait_for_port_release( + port: int, timeout: float = 10.0, host: str = _DEFAULT_HOST +) -> bool: """Poll until ``port`` is released or ``timeout`` elapses. Used after ``stop_langgraph_dev`` / ``_kill_owned_stale_process`` to @@ -364,13 +410,13 @@ def _wait_for_port_release(port: int, timeout: float = 10.0) -> bool: True if the port is free, False on timeout. """ deadline = time.monotonic() + timeout - while _is_port_occupied(port) and time.monotonic() < deadline: + while _is_port_occupied(port, host) and time.monotonic() < deadline: time.sleep(0.5) - return not _is_port_occupied(port) + return not _is_port_occupied(port, host) -def _can_bind_port(port: int) -> bool: - """Return True if a fresh ``bind()`` to ``port`` succeeds right now. +def _can_bind_port(port: int, host: str = _DEFAULT_HOST) -> bool: + """Return True if a fresh ``bind()`` to ``host:port`` succeeds right now. More reliable than ``_is_port_occupied`` when the previous listener has just exited: ``connect_ex`` can already report "free" while ``bind()`` @@ -378,12 +424,19 @@ def _can_bind_port(port: int) -> bool: (TIME_WAIT for accepted connections, SO_REUSEADDR rules, etc.). This 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. """ import socket as _socket - s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + family = _socket.AF_INET6 if ":" in host else _socket.AF_INET + s = _socket.socket(family, _socket.SOCK_STREAM) try: - s.bind(("127.0.0.1", port)) + s.bind((host, port)) return True except OSError: return False @@ -394,7 +447,9 @@ def _can_bind_port(port: int) -> bool: pass -def _wait_for_port_bindable(port: int, timeout: float = 60.0) -> bool: +def _wait_for_port_bindable( + port: int, timeout: float = 60.0, host: str = _DEFAULT_HOST +) -> bool: """Poll until a real ``bind()`` to ``port`` can succeed, or timeout. Use this immediately before ``subprocess.Popen("langgraph dev")`` — @@ -408,7 +463,7 @@ def _wait_for_port_bindable(port: int, timeout: float = 60.0) -> bool: """ deadline = time.monotonic() + timeout while time.monotonic() < deadline: - if _can_bind_port(port): + if _can_bind_port(port, host): return True time.sleep(0.5) return False @@ -544,6 +599,7 @@ def start_langgraph_dev( workspace_dir: Path | None = None, *, port: int = _DEFAULT_PORT, + host: str = _DEFAULT_HOST, file_persistence: bool = True, jobs_per_worker: int = 10, deploy_mode: bool = False, @@ -557,6 +613,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. 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 @@ -609,7 +668,9 @@ def start_langgraph_dev( # only verifies PID-file ownership, so absence of a match conflates "stale # TIME_WAIT" with "foreign process". Falling through to the bind poll # disambiguates by behavior — TIME_WAIT clears, foreign listeners don't. - if not is_langgraph_dev_running(port=port) and _is_port_occupied(port): + if not is_langgraph_dev_running(port=port, host=host) and _is_port_occupied( + port, host + ): if _kill_owned_stale_process(port): logger.warning( "Cleaned up stale langgraph dev (pid from %s) on port %d", @@ -620,7 +681,7 @@ def start_langgraph_dev( # several seconds before fully releasing it. Poll until the port # is genuinely free so the upcoming bind() doesn't race a # half-released socket and crash with "Port already in use". - _wait_for_port_release(port) + _wait_for_port_release(port, host=host) else: # No owned stale PID — could be foreign or kernel-only TIME_WAIT # from a previous subprocess. Defer to the bind poll below. @@ -638,9 +699,9 @@ def start_langgraph_dev( # "Port already in use" even though our pre-checks passed. By probing # the same operation langgraph dev will do, we either wait it out or # fail clearly with an actionable message. 60s covers macOS TIME_WAIT. - if not _wait_for_port_bindable(port): + if not _wait_for_port_bindable(port, host=host): raise RuntimeError( - f"Port {port} cannot be bound after waiting 60s (kernel TIME_WAIT " + f"{host}:{port} cannot be bound after waiting 60s (kernel TIME_WAIT " f"or another process holds it). Free the port with `lsof -ti:{port}`, " f"or change ports with: `EvoSci config set langgraph_dev_port `" ) @@ -726,6 +787,11 @@ def start_langgraph_dev( # authoritative over any workspace ``.env`` (see its docstring), so a # ``.env`` in the subprocess cwd cannot shadow the caller-resolved port. sub_env["EVOSCIENTIST_LANGGRAPH_DEV_PORT"] = str(port) + # Same reasoning for the bind interface: the deployed agent resolves its + # self-dispatch URL from ``cfg.langgraph_dev_host``, so a host resolved by + # this caller (``EvoSci deploy --host X``) must reach the subprocess too, + # or async sub-agent launches would target whatever the config file says. + sub_env["EVOSCIENTIST_LANGGRAPH_DEV_HOST"] = host try: logger.info("Starting langgraph dev with CLI: %s", exe) @@ -735,6 +801,8 @@ def start_langgraph_dev( "dev", "--config", str(config_file), + "--host", + host, "--port", str(port), "--n-jobs-per-worker", @@ -787,9 +855,9 @@ def start_langgraph_dev( f"langgraph dev exited immediately with code {proc.returncode}.\n" f"Log tail:\n{tail}" ) - if is_langgraph_dev_running(port=port): + if is_langgraph_dev_running(port=port, host=host): logger.info( - "langgraph dev started on %s (pid=%d)", _base_url(port), proc.pid + "langgraph dev started on %s (pid=%d)", _base_url(port, host), proc.pid ) return proc time.sleep(0.5) @@ -975,6 +1043,7 @@ def _ensure_langgraph_dev_locked( """Locked critical section of ``ensure_langgraph_dev`` — must hold ``_LOCK``.""" global _ASYNC_SUBAGENTS_AVAILABLE port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT)) + host = str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST) file_persistence = bool(getattr(config, "langgraph_dev_file_persistence", True)) jobs_per_worker = int(getattr(config, "langgraph_dev_jobs_per_worker", 10)) @@ -1007,10 +1076,10 @@ def _ensure_langgraph_dev_locked( # and abort with a hard "non-langgraph process" error — turning a # clean owned restart into a permanent async-disable. Wait inline for # the kernel to release the port before continuing. - _wait_for_port_release(port) + _wait_for_port_release(port, host=host) _ASYNC_SUBAGENTS_AVAILABLE = False # cleared until restart succeeds - if is_langgraph_dev_running(port=port): + if is_langgraph_dev_running(port=port, host=host): # If WE own the running process AND it's still alive, workspace was # already verified above via _PROCESS_WORKSPACE comparison. Otherwise # — we never owned it (EvoSci deploy in another terminal, or a @@ -1028,7 +1097,7 @@ def _ensure_langgraph_dev_locked( if recorded != ws_path.resolve(): raise WorkspaceMismatchError( f"An EvoSci langgraph dev is already running on " - f"{_base_url(port)} for workspace {recorded}, but the " + f"{_base_url(port, host)} for workspace {recorded}, but the " f"current process requested workspace {ws_path}. " f"Stop the other EvoSci session (deploy / TUI / serve) " f"or rerun with --workdir {recorded}." @@ -1036,7 +1105,7 @@ def _ensure_langgraph_dev_locked( logger.info( "Reusing externally-managed langgraph dev on %s; sidecar " "confirms matching workspace %s.", - _base_url(port), + _base_url(port, host), recorded, ) else: @@ -1048,11 +1117,13 @@ def _ensure_langgraph_dev_locked( "workspace sidecar, cannot verify it matches the requested " "%s. Async sub-agents may operate on a different workspace's " "files.", - _base_url(port), + _base_url(port, host), ws_path, ) else: - logger.info("langgraph dev already running on %s, reusing", _base_url(port)) + logger.info( + "langgraph dev already running on %s, reusing", _base_url(port, host) + ) _ASYNC_SUBAGENTS_AVAILABLE = True return None @@ -1060,6 +1131,7 @@ def _ensure_langgraph_dev_locked( proc = start_langgraph_dev( workspace_dir=ws_path, port=port, + host=host, file_persistence=file_persistence, jobs_per_worker=jobs_per_worker, ) diff --git a/EvoScientist/langgraph_dev/sdk.py b/EvoScientist/langgraph_dev/sdk.py index 7eafd09..4b2e993 100644 --- a/EvoScientist/langgraph_dev/sdk.py +++ b/EvoScientist/langgraph_dev/sdk.py @@ -5,17 +5,42 @@ from __future__ import annotations from collections.abc import Mapping DEFAULT_LANGGRAPH_DEV_PORT = 6174 +# Mirrors ``config.langgraph_dev_host`` / ``manager._DEFAULT_HOST``. The value +# only matters as a stand-in for the *bind* host — ``_format_hostport`` runs it +# through ``_probe_host``, so both this and "0.0.0.0" yield the same client URL. +DEFAULT_LANGGRAPH_DEV_HOST = "127.0.0.1" LANGGRAPH_DEV_AUTH_HEADERS = {"x-auth-scheme": "langsmith"} -def langgraph_dev_url(config: object | None = None, *, port: int | None = None) -> str: - """Return the local langgraph-dev base URL for a config or explicit port.""" +def langgraph_dev_url( + config: object | None = None, + *, + port: int | None = None, + host: str | None = None, +) -> str: + """Return the local langgraph-dev base URL for a config or explicit port/host. + + This is a *client* URL, so the configured bind interface is mapped through + ``manager._probe_host``: a wildcard bind (``0.0.0.0``) still resolves to + loopback here, while a specific interface is honored so self-dispatch keeps + working when the server is pinned to one address. + """ + from .manager import _format_hostport + selected_port = ( int(port) if port is not None else int(getattr(config, "langgraph_dev_port", DEFAULT_LANGGRAPH_DEV_PORT)) ) - return f"http://localhost:{selected_port}" + selected_host = ( + host + if host is not None + else str( + getattr(config, "langgraph_dev_host", DEFAULT_LANGGRAPH_DEV_HOST) + or DEFAULT_LANGGRAPH_DEV_HOST + ) + ) + return f"http://{_format_hostport(selected_host, selected_port)}" def configured_langgraph_dev_url() -> str: diff --git a/README.md b/README.md index 99ce733..4479a78 100644 --- a/README.md +++ b/README.md @@ -454,6 +454,23 @@ 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: + +```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 deploy` takes the same flag: `EvoSci deploy --host 0.0.0.0`. + +> [!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. +> +> 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 47a7003..41bf815 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -460,6 +460,23 @@ 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: + +```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 deploy` 也支持同名参数:`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 专属的。 +> +> 网络不可信时,请让后端留在回环地址,改用 SSH 隧道访问:`ssh -L 6174:localhost:6174 -L 4716:localhost:4716 <主机>`。 +
diff --git a/tests/test_async_subagent_swap.py b/tests/test_async_subagent_swap.py index 88d36ff..faa2439 100644 --- a/tests/test_async_subagent_swap.py +++ b/tests/test_async_subagent_swap.py @@ -150,12 +150,12 @@ def test_swaps_async_flagged_subs(): # Async subs are AsyncSubAgent specs (TypedDict) pointing at the right URL. writing = by_name["writing-agent"] assert writing["graph_id"] == "writing-agent" - assert writing["url"] == "http://localhost:6174" + assert writing["url"] == "http://127.0.0.1:6174" assert writing["description"] == "write report" data = by_name["data-analysis-agent"] assert data["graph_id"] == "data-analysis-agent" - assert data["url"] == "http://localhost:6174" + assert data["url"] == "http://127.0.0.1:6174" def test_swap_uses_configured_port(): @@ -170,7 +170,48 @@ def test_swap_uses_configured_port(): ), ): out = _maybe_swap_async_subagents(subs) - assert out[0]["url"] == "http://localhost:9999" + assert out[0]["url"] == "http://127.0.0.1:9999" + + +def test_swap_uses_configured_host(): + """A backend pinned to one interface can't be self-dispatched over + loopback, so the URL must track cfg.langgraph_dev_host too.""" + cfg = SimpleNamespace( + enable_async_subagents=True, + langgraph_dev_port=6174, + langgraph_dev_host="192.168.1.5", + ) + subs = [_sub("writing-agent", async_flag=True)] + with ( + patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg), + patch( + "EvoScientist.langgraph_dev.manager.is_async_subagents_available", + return_value=True, + ), + ): + out = _maybe_swap_async_subagents(subs) + assert out[0]["url"] == "http://192.168.1.5:6174" + + +def test_swap_maps_wildcard_host_to_loopback(): + """A 0.0.0.0 bind includes loopback, and connecting *to* 0.0.0.0 is + rejected outright on Windows — the client URL must collapse to + 127.0.0.1 rather than echo the bind address back.""" + cfg = SimpleNamespace( + enable_async_subagents=True, + langgraph_dev_port=6174, + langgraph_dev_host="0.0.0.0", + ) + subs = [_sub("writing-agent", async_flag=True)] + with ( + patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg), + patch( + "EvoScientist.langgraph_dev.manager.is_async_subagents_available", + return_value=True, + ), + ): + out = _maybe_swap_async_subagents(subs) + assert out[0]["url"] == "http://127.0.0.1:6174" # ============================================================================= diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 27578ff..a596c5d 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -3,6 +3,7 @@ Verifies the orchestration: - workspace resolution (CLI > config > cwd) - port resolution (CLI > config > default) +- host resolution (CLI > config > default) + the public-bind warning - port collision pre-flight - ccproxy lifecycle (only if OAuth configured) - ``start_langgraph_dev(deploy_mode=True)`` invocation @@ -24,6 +25,7 @@ def _make_config( *, default_workdir: str = "", langgraph_dev_port: int = 6174, + langgraph_dev_host: str = "127.0.0.1", anthropic_auth_mode: str = "api_key", openai_auth_mode: str = "api_key", log_level: str = "warning", @@ -34,6 +36,7 @@ def _make_config( return SimpleNamespace( default_workdir=default_workdir, langgraph_dev_port=langgraph_dev_port, + langgraph_dev_host=langgraph_dev_host, anthropic_auth_mode=anthropic_auth_mode, openai_auth_mode=openai_auth_mode, log_level=log_level, @@ -70,6 +73,7 @@ def _run_deploy_once( *, workdir: str | None = None, port: int | None = None, + host: str | None = None, debug: bool = False, cwd: str | None = None, port_occupied: bool = False, @@ -89,6 +93,8 @@ def _run_deploy_once( "deploy_mode_passed": None, "workspace_passed": None, "port_passed": None, + "host_passed": None, + "printed": [], "atexit_callbacks": [], } @@ -101,7 +107,7 @@ def _run_deploy_once( monkeypatch.setattr(config_mod, "get_effective_config", _fake_get_effective_config) monkeypatch.setattr(config_mod, "apply_config_to_env", lambda _cfg: None) - monkeypatch.setattr(deploy_server, "console", _SilentConsole()) + monkeypatch.setattr(deploy_server, "console", _SilentConsole(captured["printed"])) # Workspace setup mocks from EvoScientist import paths as paths_mod @@ -112,7 +118,7 @@ def _run_deploy_once( # langgraph_dev.manager mocks from EvoScientist.langgraph_dev import manager as lgm - monkeypatch.setattr(lgm, "_is_port_occupied", lambda _p: port_occupied) + monkeypatch.setattr(lgm, "_is_port_occupied", lambda _p, *_a, **_kw: port_occupied) monkeypatch.setattr( lgm, "is_langgraph_dev_running", @@ -123,6 +129,7 @@ def _run_deploy_once( workspace_dir=None, *, port=None, + host=None, file_persistence=True, jobs_per_worker=10, deploy_mode=False, @@ -131,6 +138,7 @@ def _run_deploy_once( captured["langgraph_dev_started"] = True captured["workspace_passed"] = str(workspace_dir) if workspace_dir else None captured["port_passed"] = port + captured["host_passed"] = host captured["deploy_mode_passed"] = deploy_mode captured["jobs_per_worker_passed"] = jobs_per_worker captured["file_persistence_passed"] = file_persistence @@ -204,17 +212,27 @@ def _run_deploy_once( if cwd is not None: monkeypatch.setattr(os, "getcwd", lambda: cwd) - deploy_server.deploy(workdir=workdir, port=port, debug=debug, tunnel=tunnel) + deploy_server.deploy( + workdir=workdir, port=port, host=host, debug=debug, tunnel=tunnel + ) return captured class _SilentConsole: """Stand-in for the Rich console — swallows all output so test runs don't spew ANSI to the captured pytest output (but doesn't break the - code paths that call ``console.print`` / ``console.status``).""" + code paths that call ``console.print`` / ``console.status``). + + Optionally records what was printed so tests can assert on banners + (e.g. the public-bind warning) without letting them reach the terminal. + """ + + def __init__(self, sink: list | None = None): + self._sink = sink def print(self, *args, **kwargs): - pass + if self._sink is not None: + self._sink.append(" ".join(str(a) for a in args)) def status(self, *args, **kwargs): class _Ctx: @@ -302,6 +320,110 @@ def test_deploy_port_defaults_to_config(monkeypatch, tmp_path): assert captured["port_passed"] == 6543 +def test_deploy_host_cli_arg_beats_config(monkeypatch, tmp_path): + config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host="127.0.0.1") + captured = _run_deploy_once(monkeypatch, config, host="192.168.1.5") + + assert captured["host_passed"] == "192.168.1.5" + + +def test_deploy_host_defaults_to_config(monkeypatch, tmp_path): + config = _make_config( + default_workdir=str(tmp_path), langgraph_dev_host="192.168.1.5" + ) + captured = _run_deploy_once(monkeypatch, config) + + assert captured["host_passed"] == "192.168.1.5" + + +def test_deploy_host_falls_back_when_config_lacks_field(monkeypatch, tmp_path): + """A config object missing the field entirely still resolves to the + module default rather than passing None down to socket.bind().""" + config = _make_config(default_workdir=str(tmp_path)) + del config.langgraph_dev_host + captured = _run_deploy_once(monkeypatch, config) + + 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)) + captured = _run_deploy_once(monkeypatch, config, host=" ") + + assert captured["host_passed"] == "127.0.0.1" + + +def test_deploy_host_public_opt_in(monkeypatch, tmp_path): + """``--host 0.0.0.0`` must actually widen the bind even though the config + default keeps the unauthenticated backend on loopback.""" + config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host="127.0.0.1") + captured = _run_deploy_once(monkeypatch, config, host="0.0.0.0") + + assert captured["host_passed"] == "0.0.0.0" + + +def test_deploy_host_is_stripped(monkeypatch, tmp_path): + config = _make_config(default_workdir=str(tmp_path)) + captured = _run_deploy_once(monkeypatch, config, host=" 0.0.0.0 ") + + assert captured["host_passed"] == "0.0.0.0" + + +def test_deploy_config_host_is_stripped(monkeypatch, tmp_path): + """Stripping must not depend on the value arriving via --host. + ``EvoScientistConfig.__post_init__`` normalizes these fields, but deploy() + reads through ``getattr`` and is handed duck-typed config objects that + never run it — an unstripped value would reach socket.bind().""" + config = _make_config( + default_workdir=str(tmp_path), langgraph_dev_host=" 192.168.1.5 " + ) + captured = _run_deploy_once(monkeypatch, config) + + assert captured["host_passed"] == "192.168.1.5" + + +def test_deploy_whitespace_config_host_collapses_to_default(monkeypatch, tmp_path): + config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host=" ") + captured = _run_deploy_once(monkeypatch, config) + + assert captured["host_passed"] == "127.0.0.1" + + +def test_deploy_padded_loopback_config_host_suppresses_warning(monkeypatch, tmp_path): + """Consequence of the unstripped path: `_is_loopback_host` would not match + " 127.0.0.1 ", so a padded loopback config would print a false PUBLIC BIND + warning while binding a value socket.bind() rejects outright.""" + config = _make_config( + default_workdir=str(tmp_path), langgraph_dev_host=" 127.0.0.1 " + ) + captured = _run_deploy_once(monkeypatch, config) + + assert captured["host_passed"] == "127.0.0.1" + assert not any("PUBLIC BIND" in line for line in captured["printed"]) + + +@pytest.mark.parametrize("exposed_host", ["0.0.0.0", "192.168.1.5", "::"]) +def test_deploy_warns_on_public_bind(monkeypatch, tmp_path, exposed_host): + config = _make_config(default_workdir=str(tmp_path)) + captured = _run_deploy_once(monkeypatch, config, host=exposed_host) + + assert any("PUBLIC BIND" in line for line in captured["printed"]), ( + f"binding {exposed_host} reaches other machines and MUST warn" + ) + + +@pytest.mark.parametrize("loopback_host", ["127.0.0.1", "::1", "localhost"]) +def test_deploy_no_warning_on_loopback_bind(monkeypatch, tmp_path, loopback_host): + config = _make_config(default_workdir=str(tmp_path)) + captured = _run_deploy_once(monkeypatch, config, host=loopback_host) + + assert not any("PUBLIC BIND" in line for line in captured["printed"]), ( + f"{loopback_host} is unreachable off-box — warning would be noise" + ) + + @pytest.mark.parametrize("bad_port", [0, -1, 70000]) def test_deploy_refuses_invalid_port(monkeypatch, tmp_path, bad_port): """CLI must reject out-of-range ports (port=0 was the original silent-fail diff --git a/tests/test_cli_tui_dispatch.py b/tests/test_cli_tui_dispatch.py index 508ea9a..9b5405d 100644 --- a/tests/test_cli_tui_dispatch.py +++ b/tests/test_cli_tui_dispatch.py @@ -41,19 +41,24 @@ def _invoke_main(monkeypatch, argv): def _fake_config(overrides): cfg = EvoScientistConfig() + calls["overrides"] = dict(overrides or {}) + # Apply every override the real merge would, so tests can assert on + # flags (like --host) that reach the config rather than the callback. + for key, value in (overrides or {}).items(): + setattr(cfg, key, value) # Mirror the real --ui override; default to webui for this test. cfg.ui_backend = overrides.get("ui_backend") or "webui" return cfg + def _fake_run_webui(config, **_kw): + calls["dispatch"] = "webui" + calls["webui_config"] = config + monkeypatch.setattr(cfg_mod, "get_effective_config", _fake_config) monkeypatch.setattr(cfg_mod, "apply_config_to_env", lambda cfg: None) monkeypatch.setattr(cmds, "ensure_dirs", lambda: None) monkeypatch.setattr(cmds, "_ensure_async_subagent_server", lambda *a, **k: None) - monkeypatch.setattr( - webui_mod, - "run_webui", - lambda *a, **k: calls.__setitem__("dispatch", "webui"), - ) + monkeypatch.setattr(webui_mod, "run_webui", _fake_run_webui) monkeypatch.setattr( interactive_mod, "cmd_interactive", @@ -79,6 +84,103 @@ def test_main_callback_resume_falls_back_to_cli(monkeypatch): assert calls.get("dispatch") == ("cli", "cli") +# ============================================================================= +# --host override +# ============================================================================= + + +def test_host_flag_drives_both_servers(monkeypatch): + """One flag, both halves. In WebUI mode the front-end and backend are two + halves of one surface, so `--host` has to move them together — widening + only one leaves the UI loading but unable to reach the agent.""" + calls, result = _invoke_main(monkeypatch, ["--host", "0.0.0.0"]) + + assert result.exit_code == 0 + cfg = calls["webui_config"] + assert cfg.webui_host == "0.0.0.0" + assert cfg.langgraph_dev_host == "0.0.0.0" + + +def test_host_flag_is_stripped(monkeypatch): + calls, result = _invoke_main(monkeypatch, ["--host", " 192.168.1.5 "]) + + assert result.exit_code == 0 + assert calls["webui_config"].langgraph_dev_host == "192.168.1.5" + + +def test_blank_host_flag_leaves_config_defaults(monkeypatch): + """An all-whitespace value must not write an unusable empty host into the + override dict, where it would beat the config file.""" + calls, result = _invoke_main(monkeypatch, ["--host", " "]) + + assert result.exit_code == 0 + assert "langgraph_dev_host" not in calls["overrides"] + assert calls["webui_config"].langgraph_dev_host == "127.0.0.1" + + +def test_no_host_flag_leaves_config_defaults(monkeypatch): + calls, result = _invoke_main(monkeypatch, []) + + assert result.exit_code == 0 + assert "webui_host" not in calls["overrides"] + assert calls["webui_config"].webui_host == "0.0.0.0" + + +def _run_ensure_backend(monkeypatch, config, *, server_up=True): + """Drive ``_ensure_async_subagent_server`` and capture console output.""" + import EvoScientist.cli.commands as cmds + + printed: list[str] = [] + monkeypatch.setattr( + "EvoScientist.langgraph_dev.manager.ensure_langgraph_dev", + lambda config, *, workspace_dir: None, + ) + monkeypatch.setattr( + "EvoScientist.langgraph_dev.manager.is_async_subagents_available", + lambda: server_up, + ) + monkeypatch.setattr(cmds, "_reconcile_autoskill_schedule", lambda *a, **k: None) + monkeypatch.setattr( + cmds.console, "print", lambda *a, **k: printed.append(str(a[0]) if a else "") + ) + monkeypatch.setattr( + cmds.console, + "status", + lambda *a, **k: __import__("contextlib").nullcontext(), + ) + cmds._ensure_async_subagent_server(config, workspace_dir="/tmp/workspace") + return printed + + +@pytest.mark.parametrize("exposed", ["0.0.0.0", "192.168.1.5", "::"]) +def test_cli_mode_warns_on_public_backend_bind(monkeypatch, exposed): + """The langgraph dev backend is shared across UI modes, so a plain + `EvoSci` session must warn too — otherwise `--host 0.0.0.0` (or a config + file with it) puts an unauthenticated shell-capable API on the network in + every mode with no signal.""" + config = SimpleNamespace(langgraph_dev_host=exposed) + printed = _run_ensure_backend(monkeypatch, config) + + assert any("PUBLIC BIND" in line for line in printed) + + +@pytest.mark.parametrize("loopback", ["127.0.0.1", "::1", "localhost"]) +def test_cli_mode_silent_on_loopback_backend_bind(monkeypatch, loopback): + config = SimpleNamespace(langgraph_dev_host=loopback) + printed = _run_ensure_backend(monkeypatch, config) + + assert not any("PUBLIC BIND" in line for line in printed) + + +def test_no_warning_when_backend_failed_to_start(monkeypatch): + """ensure_langgraph_dev fails soft (async degrades to in-process). Warning + about a bind that never happened is worse than saying nothing.""" + config = SimpleNamespace(langgraph_dev_host="0.0.0.0") + printed = _run_ensure_backend(monkeypatch, config, server_up=False) + + assert not any("PUBLIC BIND" in line for line in printed) + + def test_background_agent_server_starts_even_when_async_subagents_disabled( monkeypatch, ): diff --git a/tests/test_config.py b/tests/test_config.py index ad912e9..a7897b6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -154,6 +154,47 @@ 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. + + 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. + """ + config = EvoScientistConfig() + + assert config.langgraph_dev_host == "127.0.0.1" + assert config.webui_host == "0.0.0.0" + + @pytest.mark.parametrize( + ("field", "default"), + [("langgraph_dev_host", "127.0.0.1"), ("webui_host", "0.0.0.0")], + ) + @pytest.mark.parametrize("blank", ["", " ", "\t"]) + def test_blank_bind_host_falls_back_to_default(self, field, blank, default): + """A blank host would reach socket.bind() verbatim and surface as an + opaque gaierror; it degrades to the field's own default instead.""" + config = EvoScientistConfig(**{field: blank}) + assert getattr(config, field) == default + + @pytest.mark.parametrize( + ("field", "value"), + [("langgraph_dev_host", "0.0.0.0"), ("webui_host", "127.0.0.1")], + ) + 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.""" + config = EvoScientistConfig(**{field: value}) + assert getattr(config, field) == value + + @pytest.mark.parametrize("field", ["langgraph_dev_host", "webui_host"]) + def test_bind_host_is_stripped(self, field): + config = EvoScientistConfig(**{field: " 0.0.0.0 "}) + assert getattr(config, field) == "0.0.0.0" + def test_auth_mode_default(self): """Test that anthropic_auth_mode defaults to api_key.""" config = EvoScientistConfig() @@ -981,6 +1022,26 @@ class TestDotenvIsolation: assert config.langgraph_dev_port == 6606 assert os.environ["EVOSCIENTIST_LANGGRAPH_DEV_PORT"] == "6606" + @pytest.mark.parametrize( + ("field", "env_var", "value"), + [ + ("langgraph_dev_host", "EVOSCIENTIST_LANGGRAPH_DEV_HOST", "0.0.0.0"), + ("webui_host", "EVOSCIENTIST_WEBUI_HOST", "127.0.0.1"), + ], + ) + def test_bind_hosts_are_env_overridable( + self, temp_config_dir, monkeypatch, field, env_var, value + ): + """``start_langgraph_dev`` propagates the resolved bind host to the + subprocess through ``EVOSCIENTIST_LANGGRAPH_DEV_HOST``, so both host + fields must be declared in ``_ENV_MAPPINGS`` or the subprocess would + fall back to whatever the config file says.""" + monkeypatch.setenv(env_var, value) + + config = get_effective_config() + + assert getattr(config, field) == value + def test_dotenv_wins_over_shell_for_third_party_api_keys( self, temp_config_dir, tmp_path, monkeypatch ): diff --git a/tests/test_langgraph_dev_deploy_mode.py b/tests/test_langgraph_dev_deploy_mode.py index f94a74a..523f30f 100644 --- a/tests/test_langgraph_dev_deploy_mode.py +++ b/tests/test_langgraph_dev_deploy_mode.py @@ -34,13 +34,16 @@ def _patch_start_prereqs(monkeypatch, tmp_path: Path, runtime_paths) -> dict: fake_config.write_text("{}") monkeypatch.setattr(manager, "_packaged_langgraph_config", lambda: fake_config) - # No conflicts, no stale process — straight to spawn. + # No conflicts, no stale process — straight to spawn. The ``**_kw`` tails + # absorb the ``host`` argument these probes now take. monkeypatch.setattr(manager, "is_langgraph_dev_running", lambda **_: False) - monkeypatch.setattr(manager, "_is_port_occupied", lambda _port: False) - monkeypatch.setattr(manager, "_wait_for_port_bindable", lambda _port: True) + monkeypatch.setattr(manager, "_is_port_occupied", lambda _port, *_a, **_kw: False) + monkeypatch.setattr( + manager, "_wait_for_port_bindable", lambda _port, *_a, **_kw: True + ) monkeypatch.setattr(manager, "_kill_owned_stale_process", lambda _port: False) monkeypatch.setattr( - manager, "_wait_for_port_release", lambda _port, timeout=10.0: True + manager, "_wait_for_port_release", lambda _port, *_a, **_kw: True ) # Redirect the log file — pid_dir already rooted under tmp via the fixture. @@ -211,6 +214,56 @@ def test_workspace_dir_env_var_set_regardless_of_mode( assert captured["env"].get("EVOSCIENTIST_WORKSPACE_DIR") == str(tmp_path) +# ============================================================================= +# Bind host — argv flag + env propagation +# ============================================================================= + + +def test_host_defaults_to_loopback_in_argv(monkeypatch, tmp_path, runtime_paths): + """``--host`` must always be emitted rather than left to the langgraph + CLI's own default, so the bind stays pinned to _DEFAULT_HOST even if that + default moves.""" + captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths) + with pytest.raises(_PopenAbort): + manager.start_langgraph_dev(workspace_dir=tmp_path, port=16178) + + args = captured["args"] + assert args[args.index("--host") + 1] == "127.0.0.1" + + +def test_explicit_wildcard_host_reaches_argv(monkeypatch, tmp_path, runtime_paths): + """The opt-in to a public bind has to survive all the way into argv.""" + captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths) + with pytest.raises(_PopenAbort): + manager.start_langgraph_dev(workspace_dir=tmp_path, port=16178, host="0.0.0.0") + + args = captured["args"] + assert args[args.index("--host") + 1] == "0.0.0.0" + + +def test_env_carries_explicit_bind_host(monkeypatch, tmp_path, runtime_paths): + """The subprocess resolves its self-dispatch URL from config, so the + caller-resolved host must be injected — mirrors the port propagation.""" + captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths) + with pytest.raises(_PopenAbort): + manager.start_langgraph_dev( + workspace_dir=tmp_path, port=16178, host="192.168.1.5" + ) + + assert captured["env"].get("EVOSCIENTIST_LANGGRAPH_DEV_HOST") == "192.168.1.5" + + +def test_env_host_replaces_inherited(monkeypatch, tmp_path, runtime_paths): + """A stray export in the user's shell must not override the host this + caller resolved — otherwise the bind and the dispatch URL desync.""" + monkeypatch.setenv("EVOSCIENTIST_LANGGRAPH_DEV_HOST", "10.0.0.9") + captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths) + with pytest.raises(_PopenAbort): + manager.start_langgraph_dev(workspace_dir=tmp_path, port=16178, host="0.0.0.0") + + assert captured["env"].get("EVOSCIENTIST_LANGGRAPH_DEV_HOST") == "0.0.0.0" + + # ============================================================================= # Module-load behavior — _ASYNC_SUBAGENTS_AVAILABLE reads env var on import # ============================================================================= diff --git a/tests/test_langgraph_manager.py b/tests/test_langgraph_manager.py index 18caa92..c3ce0cd 100644 --- a/tests/test_langgraph_manager.py +++ b/tests/test_langgraph_manager.py @@ -33,6 +33,103 @@ def reset_module_state(): manager._LOG_OFFSET_AT_START = 0 +# ============================================================================= +# Bind host vs. probe host +# ============================================================================= + + +class TestProbeHost: + """``_probe_host`` is the seam that makes host support safe: only bind() + uses the configured interface, every client falls back to something + actually reachable.""" + + @pytest.mark.parametrize("wildcard", ["0.0.0.0", "::", ""]) + def test_wildcards_map_to_loopback(self, wildcard): + assert manager._probe_host(wildcard) == "127.0.0.1" + + @pytest.mark.parametrize( + "host", ["127.0.0.1", "192.168.1.5", "::1", "example.test"] + ) + def test_specific_hosts_pass_through(self, host): + assert manager._probe_host(host) == host + + def test_defaults_to_loopback(self): + assert manager._probe_host() == "127.0.0.1" + + +class TestIsLoopbackHost: + """Drives the public-bind warning, so it must be conservative: only + provable loopback suppresses the banner.""" + + @pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost", " LOCALHOST "]) + def test_loopback_recognized(self, host): + assert manager._is_loopback_host(host) is True + + @pytest.mark.parametrize("host", ["0.0.0.0", "::", "192.168.1.5", "example.test"]) + def test_exposed_hosts_rejected(self, host): + assert manager._is_loopback_host(host) is False + + +class TestBaseUrl: + def test_default_is_loopback(self): + assert manager._base_url(6174) == "http://127.0.0.1:6174" + + def test_wildcard_renders_as_loopback(self): + assert manager._base_url(6174, "0.0.0.0") == "http://127.0.0.1:6174" + + def test_specific_host_preserved(self): + assert manager._base_url(6174, "192.168.1.5") == "http://192.168.1.5:6174" + + def test_ipv6_literal_is_bracketed(self): + """Unbracketed ``::1:6174`` is not a parseable authority (RFC 3986).""" + assert manager._base_url(6174, "::1") == "http://[::1]:6174" + + +class TestCanBindPort: + def test_binds_literal_host_not_probe_host(self, monkeypatch): + """``_can_bind_port`` must replicate the server's own bind. Probing + loopback while the server claims 0.0.0.0 would give false confidence + when another process holds a single non-loopback interface.""" + import socket as _socket + + bound: list[tuple] = [] + + class _FakeSocket: + def __init__(self, family, type_): + self.family = family + + def bind(self, addr): + bound.append((self.family, addr)) + + def close(self): + pass + + monkeypatch.setattr(_socket, "socket", _FakeSocket) + + assert manager._can_bind_port(6174, "0.0.0.0") is True + assert bound == [(_socket.AF_INET, ("0.0.0.0", 6174))] + + def test_ipv6_host_uses_ipv6_family(self, monkeypatch): + import socket as _socket + + bound: list[tuple] = [] + + class _FakeSocket: + def __init__(self, family, type_): + self.family = family + + def bind(self, addr): + bound.append((self.family, addr)) + + def close(self): + pass + + monkeypatch.setattr(_socket, "socket", _FakeSocket) + + assert manager._can_bind_port(6174, "::1") is True + assert bound == [(_socket.AF_INET6, ("::1", 6174))] + + # ============================================================================= # langgraph CLI resolution # ============================================================================= @@ -124,15 +221,43 @@ class TestIsLanggraphDevRunning: def test_returns_true_on_200(self, mock_get): mock_get.return_value = MagicMock(status_code=200) assert manager.is_langgraph_dev_running(port=6174) is True - # Verify it probed /ok at the configured port. + # Verify it probed /ok at the configured port. 127.0.0.1 rather than + # "localhost" on purpose: the latter can resolve to ::1 first, which + # never reaches a server bound to an IPv4 interface. called_url = mock_get.call_args[0][0] - assert called_url == "http://localhost:6174/ok" + assert called_url == "http://127.0.0.1:6174/ok" @patch("EvoScientist.langgraph_dev.manager.httpx.get") def test_returns_false_on_non_200(self, mock_get): mock_get.return_value = MagicMock(status_code=503) assert manager.is_langgraph_dev_running(port=6174) is False + @patch("EvoScientist.langgraph_dev.manager.httpx.get") + def test_wildcard_bind_probed_over_loopback(self, mock_get): + """A server bound to 0.0.0.0 also listens on loopback, and you cannot + meaningfully connect to 0.0.0.0 itself — probe 127.0.0.1.""" + mock_get.return_value = MagicMock(status_code=200) + assert manager.is_langgraph_dev_running(port=6174, host="0.0.0.0") is True + assert mock_get.call_args[0][0] == "http://127.0.0.1:6174/ok" + + @patch("EvoScientist.langgraph_dev.manager.httpx.get") + def test_specific_host_probed_verbatim(self, mock_get): + """Loopback would not reach a server pinned to one interface.""" + mock_get.return_value = MagicMock(status_code=200) + assert manager.is_langgraph_dev_running(port=6174, host="192.168.1.5") is True + assert mock_get.call_args[0][0] == "http://192.168.1.5:6174/ok" + + @patch("EvoScientist.langgraph_dev.manager.httpx.get") + def test_explicit_base_url_still_wins(self, mock_get): + mock_get.return_value = MagicMock(status_code=200) + assert ( + manager.is_langgraph_dev_running( + base_url="http://example.test:1234", port=6174, host="0.0.0.0" + ) + is True + ) + assert mock_get.call_args[0][0] == "http://example.test:1234/ok" + # ============================================================================= # _list_pids_on_port @@ -524,7 +649,7 @@ class TestStartLanggraphDevRotatesLog: # sockets. Patch ``_can_bind_port`` so the bind-poll loop in # ``_wait_for_port_bindable`` passes immediately regardless of # whether port 6174 is in use on the dev machine. - monkeypatch.setattr(manager, "_can_bind_port", lambda port: True) + monkeypatch.setattr(manager, "_can_bind_port", lambda port, *_a, **_kw: True) # Make ``_packaged_langgraph_config`` point at a real file so # ``start_langgraph_dev`` doesn't bail at the existence check # before reaching the rotation call. @@ -577,7 +702,7 @@ def start_langgraph_dev_capture(tmp_path, monkeypatch): log_file=log, ), ) - monkeypatch.setattr(manager, "_can_bind_port", lambda port: True) + monkeypatch.setattr(manager, "_can_bind_port", lambda port, *_a, **_kw: True) fake_config = tmp_path / "langgraph.json" fake_config.write_text("{}") monkeypatch.setattr(manager, "_langgraph_exe", lambda: "/fake/langgraph") diff --git a/tests/test_onboard.py b/tests/test_onboard.py index dcc8c88..444da48 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -434,6 +434,59 @@ class TestValidateAtlasCloudKey: # ============================================================================= +class TestStepPortsRenderConfiguredHost: + """The wizard's confirmation lines used to hard-code ``127.0.0.1`` / + ``localhost``, which lies once a bind host is pinned to a real interface. + They now render whatever the configured host resolves to.""" + + def _capture(self, step, config, answer=""): + printed: list[str] = [] + with ( + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps.console") as mock_console, + patch( + "EvoScientist.langgraph_dev.manager._is_port_occupied", + lambda *_a, **_kw: False, + ), + patch( + "EvoScientist.langgraph_dev.manager.is_langgraph_dev_running", + lambda *_a, **_kw: False, + ), + ): + mock_q.text.return_value.ask.return_value = answer + mock_console.print.side_effect = lambda *a, **k: printed.append(str(a[0])) + step(config) + return "\n".join(printed) + + def test_langgraph_dev_step_shows_pinned_interface(self): + from EvoScientist.config.onboard.steps import _step_langgraph_dev_port + + config = EvoScientistConfig( + langgraph_dev_port=6174, langgraph_dev_host="192.168.1.5" + ) + assert "http://192.168.1.5:6174" in self._capture( + _step_langgraph_dev_port, config + ) + + def test_langgraph_dev_step_shows_loopback_for_wildcard(self): + """A wildcard bind is reported as loopback — that is the address this + machine's own browser opens.""" + from EvoScientist.config.onboard.steps import _step_langgraph_dev_port + + config = EvoScientistConfig( + langgraph_dev_port=6174, langgraph_dev_host="0.0.0.0" + ) + assert "http://127.0.0.1:6174" in self._capture( + _step_langgraph_dev_port, config + ) + + def test_webui_step_shows_pinned_interface(self): + from EvoScientist.config.onboard.steps import _step_webui_port + + config = EvoScientistConfig(webui_port=4716, webui_host="192.168.1.5") + assert "http://192.168.1.5:4716" in self._capture(_step_webui_port, config) + + class TestStepProvider: def test_returns_selected_provider(self): """Test that _step_provider returns selected provider.""" diff --git a/tests/test_webui_launcher.py b/tests/test_webui_launcher.py new file mode 100644 index 0000000..63f42e2 --- /dev/null +++ b/tests/test_webui_launcher.py @@ -0,0 +1,271 @@ +"""Tests for ``run_webui`` bind-host wiring. + +The front-end is an external npm package (``@evoscientist/webui``) with no +``--host`` flag: its bin launcher does +``HOSTNAME: process.env.HOSTNAME || "127.0.0.1"`` and hands that to the Next +standalone server. Setting ``HOSTNAME`` on the npx env is therefore the *only* +supported way to widen the front-end's interface — these tests pin that +contract so a refactor can't quietly drop it and silently re-narrow the bind. +""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace +from typing import Any + +import pytest + +from EvoScientist.deploy import webui as webui_mod + + +def _make_config( + *, + default_workdir: str = "", + langgraph_dev_port: int = 6174, + langgraph_dev_host: str = "127.0.0.1", + webui_port: int = 4716, + webui_host: str = "0.0.0.0", +): + return SimpleNamespace( + default_workdir=default_workdir, + langgraph_dev_port=langgraph_dev_port, + langgraph_dev_host=langgraph_dev_host, + webui_port=webui_port, + webui_host=webui_host, + langgraph_dev_jobs_per_worker=10, + langgraph_dev_file_persistence=True, + ) + + +class _RecordingConsole: + """A real Rich console rendering to a buffer. + + Rendering for real (rather than stringifying the arguments) matters here: + the remote-backend hint lives *inside* a ``Panel``, so a naive ``str(arg)`` + would only ever see ```` and the assertion + would pass or fail for the wrong reason. Width is pinned wide so the + strings under test don't wrap mid-token. + """ + + def __init__(self, sink: list): + import io + + from rich.console import Console + + self._sink = sink + self._buf = io.StringIO() + self._console = Console(file=self._buf, width=200, no_color=True) + + def print(self, *args, **kwargs): + self._buf.seek(0) + self._buf.truncate() + self._console.print(*args, **kwargs) + self._sink.append(self._buf.getvalue()) + + def status(self, *args, **kwargs): + class _Ctx: + def __enter__(self_inner): + return self_inner + + def __exit__(self_inner, *a): + return False + + return _Ctx() + + +class _ImmediateEvent: + """Exits ``run_webui``'s block loop after a single iteration.""" + + def __init__(self): + self._called = 0 + + def is_set(self) -> bool: + self._called += 1 + return self._called > 1 + + def wait(self, timeout: float | None = None): + return None + + def set(self): + self._called = 99 + + +def _run_webui_once(monkeypatch, config, *, backend_port_occupied: bool = False): + """Run ``run_webui`` with every external dependency mocked.""" + import atexit + import os + import shutil + import signal + import threading + + import EvoScientist.config as config_mod + from EvoScientist.langgraph_dev import manager as lgm + + captured: dict[str, Any] = {"printed": [], "npx_env": {}, "npx_args": []} + + monkeypatch.setattr(config_mod, "apply_config_to_env", lambda _cfg: None) + monkeypatch.setattr(webui_mod, "console", _RecordingConsole(captured["printed"])) + monkeypatch.setattr(os, "makedirs", lambda *a, **k: None) + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + + monkeypatch.setattr( + lgm, "_is_port_occupied", lambda _p, *_a, **_kw: backend_port_occupied + ) + monkeypatch.setattr(lgm, "is_langgraph_dev_running", lambda **_kw: False) + monkeypatch.setattr(lgm, "_read_workspace_sidecar", lambda: None) + + def _fake_start_langgraph_dev(workspace_dir=None, *, port=None, host=None, **_kw): + captured["backend_port"] = port + captured["backend_host"] = host + return SimpleNamespace(pid=99999) + + monkeypatch.setattr(lgm, "start_langgraph_dev", _fake_start_langgraph_dev) + monkeypatch.setattr(lgm, "stop_langgraph_dev", lambda *_a, **_kw: None) + + class _FakeProc: + pid = 12345 + + def poll(self): + return None + + def wait(self, timeout=None): + return 0 + + def kill(self): + return None + + def terminate(self): + return None + + def _fake_popen(args, **kwargs): + captured["npx_args"] = args + captured["npx_env"] = kwargs.get("env", {}) + return _FakeProc() + + monkeypatch.setattr(subprocess, "Popen", _fake_popen) + # _stop_webui shells out to taskkill on Windows — neutralize it. + monkeypatch.setattr(webui_mod, "_stop_webui", lambda _proc: None) + monkeypatch.setattr(atexit, "register", lambda fn, *a, **k: fn) + monkeypatch.setattr(signal, "signal", lambda _sig, _handler: lambda *a: None) + monkeypatch.setattr(threading, "Event", _ImmediateEvent) + + webui_mod.run_webui(config, workspace_dir="/tmp/ws") + return captured + + +# ============================================================================= +# Front-end bind interface (HOSTNAME) +# ============================================================================= + + +def test_hostname_env_carries_webui_host(monkeypatch): + config = _make_config(webui_host="0.0.0.0") + captured = _run_webui_once(monkeypatch, config) + + assert captured["npx_env"].get("HOSTNAME") == "0.0.0.0", ( + "HOSTNAME is the package's only bind knob — without it the front-end " + "falls back to its own 127.0.0.1 default" + ) + + +def test_hostname_env_honors_loopback_opt_out(monkeypatch): + config = _make_config(webui_host="127.0.0.1") + captured = _run_webui_once(monkeypatch, config) + + assert captured["npx_env"].get("HOSTNAME") == "127.0.0.1" + + +def test_port_env_and_flag_still_set(monkeypatch): + config = _make_config(webui_port=4800) + captured = _run_webui_once(monkeypatch, config) + + assert captured["npx_env"].get("PORT") == "4800" + assert "--port" in captured["npx_args"] + assert captured["npx_args"][captured["npx_args"].index("--port") + 1] == "4800" + + +def test_no_host_flag_passed_to_npx(monkeypatch): + """The package's arg parser only knows ``--port``; a stray ``--host`` is at + best ignored and at worst breaks startup, so we must not emit one.""" + config = _make_config() + captured = _run_webui_once(monkeypatch, config) + + assert "--host" not in captured["npx_args"] + + +@pytest.mark.parametrize("blank", ["", " "]) +def test_blank_webui_host_falls_back_to_wildcard(monkeypatch, blank): + config = _make_config(webui_host=blank) + captured = _run_webui_once(monkeypatch, config) + + assert captured["npx_env"].get("HOSTNAME") == "0.0.0.0" + + +# ============================================================================= +# Backend bind interface + security warning +# ============================================================================= + + +def test_backend_host_reaches_start_langgraph_dev(monkeypatch): + config = _make_config(langgraph_dev_host="0.0.0.0") + captured = _run_webui_once(monkeypatch, config) + + assert captured["backend_host"] == "0.0.0.0" + + +def test_backend_defaults_to_loopback(monkeypatch): + """The backend is an unauthenticated API whose agent can run shell, so it + stays off the network unless ``langgraph_dev_host`` opts in.""" + config = _make_config() + captured = _run_webui_once(monkeypatch, config) + + assert captured["backend_host"] == "127.0.0.1" + + +def test_public_bind_warning_when_backend_exposed(monkeypatch): + """Users who widen the backend get told every time it is reachable + off-box — an unauthenticated, shell-capable API deserves a standing + reminder, not a one-time opt-in prompt.""" + config = _make_config(langgraph_dev_host="0.0.0.0") + captured = _run_webui_once(monkeypatch, config) + + assert any("PUBLIC BIND" in line for line in captured["printed"]) + + +def test_no_public_bind_warning_when_backend_on_loopback(monkeypatch): + """The warning must be silenceable, or it degrades into background noise + that users learn to skip past.""" + config = _make_config(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"]) + + +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.""" + 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"]) + + +def test_remote_backend_hint_when_frontend_exposed_but_backend_is_not(monkeypatch): + """The UI talks to the backend from the browser, so a remote visitor + cannot reach a loopback backend — say so instead of letting every request + fail silently.""" + config = _make_config(webui_host="0.0.0.0", langgraph_dev_host="127.0.0.1") + captured = _run_webui_once(monkeypatch, config) + + banner = "\n".join(captured["printed"]) + assert "Remote visitors cannot reach" in banner + assert "langgraph_dev_host" in banner + + +def test_no_remote_hint_when_both_exposed(monkeypatch): + config = _make_config(webui_host="0.0.0.0", langgraph_dev_host="0.0.0.0") + captured = _run_webui_once(monkeypatch, config) + + banner = "\n".join(captured["printed"]) + assert "Remote visitors cannot reach" not in banner