Files
EvoScientist-Multi/EvoScientist/deploy/webui.py
T
Xiaohui Yan 3c5cc831c0 Feat/configurable bind host (#402)
* feat: configurable bind host for WebUI and langgraph dev (refs #400)

WebUI mode was only reachable from the machine running it: the front-end
got no bind interface, and `start_langgraph_dev(...)` was called without a
host, so both servers stayed on loopback with no way to widen them.

Adds two config fields with deliberately different defaults:

  webui_host        = 0.0.0.0    front-end serves the app shell, no secrets
  langgraph_dev_host = 127.0.0.1  unauthenticated API, agent can run shell

The design hinges on separating bind address from client address. Only
bind() uses the configured interface; every consumer that *connects*
(health probes, occupancy checks, async sub-agent self-dispatch) goes
through the new `_probe_host`, which maps a wildcard bind back to
loopback and honors a pinned interface verbatim. `_can_bind_port` is the
one exception and binds the literal host, since it must replicate the
bind the server itself will attempt.

  - manager.py: `_probe_host`, `_is_loopback_host`, `_format_hostport`;
    host kwarg threaded through the probes and `start_langgraph_dev`,
    which now emits `--host` and propagates
    EVOSCIENTIST_LANGGRAPH_DEV_HOST to the subprocess
  - sdk.py: `langgraph_dev_url` tracks host as well as port;
    EvoScientist.py reuses it instead of an inline f-string
  - server.py: `--host` flag mirroring `--port`, plus a red PUBLIC BIND
    banner whenever the bind is not provably loopback
  - webui.py: forwards both hosts; the front-end is widened via HOSTNAME
    because @evoscientist/webui ships no --host flag — its bin launcher
    does `HOSTNAME: process.env.HOSTNAME || "127.0.0.1"`. The warning is
    gated on the backend host only, so the shipped front-end default
    doesn't print a banner on every launch

Verified end to end against a live server: requesting 0.0.0.0 yields a
socket listening on 0.0.0.0 with the health probe correctly resolved to
127.0.0.1, while the default still binds 127.0.0.1 only.

Note: webui_host defaulting to 0.0.0.0 is a behavior change — upgrading
users will find the front-end reachable from the LAN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: default both bind hosts to 0.0.0.0, add --host and wizard host rendering (closes #400)

Completes the remaining items from #400.

  - `langgraph_dev_host` now defaults to 0.0.0.0, matching `webui_host`.
    Remote WebUI use needs both anyway (the UI reaches the backend from the
    browser, not server-side), so a loopback backend default just meant every
    remote user hit a silently failing UI. `_DEFAULT_HOST` and sdk's
    `DEFAULT_LANGGRAPH_DEV_HOST` follow, so there is one story about where
    these servers listen.

    SECURITY: this exposes an unauthenticated API whose agent can run shell
    commands. The red PUBLIC BIND banner consequently fires on every launch
    while exposed — kept deliberately, since the exposure is real and the
    escape hatch (`--host 127.0.0.1` / `config set langgraph_dev_host`) is
    only discoverable if we say so. READMEs now lead with the warning and
    document the SSH-tunnel alternative.

  - `EvoSci --host <ip>` on the WebUI launch path, driving both servers. In
    WebUI mode they are two halves of one surface; moving only one leaves the
    UI loading but unable to reach the agent. Blank values are dropped rather
    than written as an empty override that would beat the config file.

  - Onboarding wizard no longer prints hard-coded `http://127.0.0.1:{port}` /
    `http://localhost:{port}` (steps.py:160, :223) — both render the
    configured bind through `_base_url` / `_format_hostport`, so a pinned
    interface is reported honestly and a wildcard still shows loopback.

Verified against a live server: with no host argument at all, resolution
through EvoScientistConfig yields a socket listening on 0.0.0.0, a client URL
of http://127.0.0.1, and the warning gate returning True.

Still open and tracked separately: the front-end takes its backend URL from
browser input: `@evoscientist/webui` reads only HOSTNAME, PORT and
EVOSCIENTIST_LANGGRAPH_DEV_PORT, so advertising a backend URL needs a change
in that repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: bump setup-uv v6 -> v9.0.0 to drop the deprecated node20 runtime

GitHub now warns that setup-uv@v6 targets Node.js 20 and is being forced
onto Node.js 24. v7.0.0 is the release that made that switch, so anything
>= v7 clears the warning; v9.0.0 is current.

Pinned to the full tag deliberately: setup-uv stopped publishing major and
minor tags in v8.0.0 as supply-chain hardening, so `@v9` and `@v8` return
404 and would fail the job outright. Releases are immutable from v8 on, so
the full tag is as tamper-proof as a SHA. Comment left in lint.yml because
"simplifying" this back to `@v9` is an easy and CI-breaking mistake.

actions/checkout@v5 is already node24 and needs no change.

Note: v9.0.0 flips the `prune-cache` default to false (upstream did this to
ease load on PyPI infrastructure). None of these workflows set it, so they
follow the new default and Actions cache usage may grow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): correct --host help text and warn on public bind in non-WebUI modes

The --host help claimed "WebUI mode only", which is wrong in a way that
matters for security. `--host` writes `langgraph_dev_host` unconditionally,
and `_ensure_async_subagent_server` auto-starts that backend for tui / cli /
serve as well — the langgraph dev server is shared across UI modes. So the
flag narrows or widens the agent API in every mode, and only `webui_host` is
actually WebUI-specific. Reported against cli/commands.py.

The documentation error hid a real gap: the PUBLIC BIND banner lived only in
deploy/server.py and deploy/webui.py, so a plain `EvoSci` session bound
0.0.0.0 with no runtime signal whatsoever — and `--help` is opt-in, so
fixing the text alone would not surface it. Added the same banner to the
shared CLI path, gated on `is_async_subagents_available()`: ensure_langgraph_dev
fails soft (async degrades to in-process delegation), and warning about a
bind that never happened would be worse than staying quiet.

READMEs (EN + zh-CN) get the same correction — the warning block sat inside
the Desktop WebUI section and read as WebUI-scoped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deploy): strip the config-derived bind host, not just the CLI one

`deploy()` only stripped the `--host` branch. When the flag was omitted,
`getattr(config, "langgraph_dev_host", ...)` flowed unstripped into
`_is_port_occupied`, `is_langgraph_dev_running`, `start_langgraph_dev` and
the banner. `run_webui` already strips unconditionally; this aligns the two.

Reachable because `deploy()` reads through `getattr` and is routinely handed
duck-typed config objects (tests, embedders) that never run
`EvoScientistConfig.__post_init__`, which is what normally normalizes these
fields.

Worst case was not just a bad bind: `_is_loopback_host(" 127.0.0.1 ")` is
False, so a padded loopback value would print a false PUBLIC BIND warning
while binding a string socket.bind() rejects outright — a security banner
saying the opposite of the truth.

Three regression tests added, each verified to fail against the old code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: apply ruff format to the bind-host changes

The Lint workflow runs both `ruff check` and `ruff format --check`; I had
only been running the former locally, so five files landed unformatted and
failed CI. Whitespace and line-wrapping only — no semantic change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): keep the langgraph dev backend on loopback by default

The backend is an unauthenticated API whose agent can run shell commands,
and it is auto-started in every UI mode (tui/cli/webui/serve/deploy) — so a
0.0.0.0 default put it on the network for users who never asked. Restore
127.0.0.1 as the default and make 0.0.0.0 an explicit opt-in.

webui_host keeps its 0.0.0.0 default: the front-end serves the app shell
only and holds no credentials. run_webui already prints a remote-backend
hint when the front-end is exposed and the backend is not.

Help text and both READMEs are reframed around widening rather than
narrowing; the escape-hatch tests are inverted to assert the public-bind
opt-in survives into argv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:15:49 +08:00

368 lines
15 KiB
Python

"""``EvoSci`` WebUI mode — deploy-style LangGraph server + browser front-end.
Selected via ``ui_backend = "webui"`` (onboard → "Select UI mode" → WebUI).
Running ``EvoSci`` then becomes, in ONE terminal:
EvoSci deploy + npx @evoscientist/webui
i.e. start a *full* langgraph dev server (MCP + async sub-agents, exactly like
``EvoSci deploy``) AND launch the published ``@evoscientist/webui`` Next.js
front-end via ``npx``, so the user never needs two terminals.
Design boundary: this module deliberately REUSES the low-level
``start_langgraph_dev`` primitive but does **not** import, call, or modify the
``deploy`` command. ``EvoSci deploy`` stays a clean, opinionated standalone
server for *external* consumers (deep-agents-ui, agent-chat-ui, LangSmith
Studio, SDK clients); WebUI mode is a separate, parallel launcher.
``npx @evoscientist/webui@latest`` is used (not a pinned version) so each launch
transparently pulls the newest published UI — front-end fixes ship to users
without touching the EvoScientist install. The trade-off: the first launch (and
the first launch after a new release) downloads the package and needs network;
subsequent launches reuse the npm cache.
"""
from __future__ import annotations
import atexit
import os
import shutil
import signal
import subprocess
import threading
from pathlib import Path
from typing import Any
import typer # type: ignore[import-untyped]
from rich.panel import Panel
from rich.text import Text
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:
"""Start the deploy-style backend + the WebUI front-end, then block.
Args:
config: Effective ``EvoScientistConfig`` (already env-applied upstream,
but re-applied here so this is safe to call standalone).
workspace_dir: Resolved workspace path; falls back to
``config.default_workdir`` then cwd.
Blocks until Ctrl+C / SIGTERM, or until the front-end process exits, then
tears down both subprocesses. Never returns a value.
"""
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,
start_langgraph_dev,
stop_langgraph_dev,
)
apply_config_to_env(config)
# 1. Resolve workspace (CLI-resolved value > config.default_workdir > cwd),
# mirroring `EvoSci deploy`. The langgraph dev subprocess inherits this via
# EVOSCIENTIST_WORKSPACE_DIR (set inside start_langgraph_dev).
if workspace_dir:
ws = os.path.abspath(os.path.expanduser(workspace_dir))
elif getattr(config, "default_workdir", ""):
ws = os.path.abspath(os.path.expanduser(config.default_workdir))
else:
ws = os.getcwd()
os.makedirs(ws, exist_ok=True)
# 2. Resolve ports: backend = langgraph dev (browser connects here),
# 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(
f"[red]Invalid {label} port {p}. Use an integer in [1, 65535].[/red]"
)
raise typer.Exit(1)
if webui_port == backend_port:
# Same port → the backend would claim it first and npx would fail to
# bind. Catch it here with a clear message instead of a cryptic error.
console.print(
f"[red]WebUI port and langgraph dev port must differ "
f"(both are {webui_port}).[/red]"
)
console.print(
"[dim]Change one with [bold]EvoSci config set webui_port <port>"
"[/bold].[/dim]"
)
raise typer.Exit(1)
# 3. Pre-flight the npx front-end requirement BEFORE starting the server,
# so a missing Node toolchain fails fast with actionable guidance.
npx = shutil.which("npx")
if not npx:
console.print(
Panel(
Text.from_markup(
"[bold]Node.js / npx was not found on PATH.[/bold]\n\n"
"The WebUI front-end ships as the npm package "
"[cyan]@evoscientist/webui[/cyan] and is launched with "
"[bold]npx[/bold].\n\n"
"Install [bold]Node.js 24 LTS[/bold] (which includes npx), "
"then re-run [bold]EvoSci[/bold] — or switch UI modes with "
"[bold]EvoSci config set ui_backend tui[/bold]."
),
title="[bold red]WebUI unavailable[/bold red]",
border_style="red",
)
)
raise typer.Exit(1)
# 4. Backend (langgraph dev): reuse an EvoSci server already on the port,
# 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, 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
# workspace A. No sidecar (older subprocess) → reuse, as before.
sidecar = _read_workspace_sidecar()
if (
sidecar is not None
and Path(sidecar["workspace"]).resolve() != Path(ws).resolve()
):
console.print(
f"[red]Port {backend_port} is already serving a langgraph "
f"dev for a different workspace "
f"({_shorten(sidecar['workspace'])}).[/red]"
)
console.print(
f"[dim]Stop that EvoSci session, or launch from that "
f"workspace ([bold]--workdir {sidecar['workspace']}[/bold])."
f"[/dim]"
)
raise typer.Exit(1)
console.print(
f"[green]✓[/green] Reusing langgraph dev already serving "
f"port {backend_port}"
)
else:
console.print(
f"[red]Port {backend_port} is occupied by another process.[/red]"
)
console.print(
f"[dim]Free it (lsof -i :{backend_port}) or change it with "
f"[bold]EvoSci config set langgraph_dev_port <port>[/bold].[/dim]"
)
raise typer.Exit(1)
else:
jobs_per_worker = int(getattr(config, "langgraph_dev_jobs_per_worker", 10))
file_persistence = bool(getattr(config, "langgraph_dev_file_persistence", True))
try:
with console.status(
"[dim]Starting langgraph dev (deploy mode: MCP + async)...[/dim]",
spinner="dots",
):
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,
)
atexit.register(stop_langgraph_dev, started_proc)
except Exception as exc:
console.print(f"[red]langgraph dev startup failed:[/red] {exc}")
raise typer.Exit(1) from exc
console.print("[green]✓[/green] langgraph dev ready")
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 "
f"[bold]EvoSci config set webui_port <port>[/bold].[/yellow]"
)
# 5. Launch the front-end via npx in its own process group so the whole
# tree (npx → node → next server) tears down cleanly on shutdown. The
# package's own launcher prints progress and opens the browser; stdio is
# 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://<this-machine-ip>:{backend_port}"
f"[/bold].\n"
)
console.print(
Panel(
Text.from_markup(
f"[bold]Backend:[/bold] {_base_url(backend_port, backend_host)} "
f"[dim](langgraph dev — Assistant: EvoScientist)[/dim]\n"
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"
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]"
),
title="[bold green]✓ EvoScientist WebUI[/bold green]",
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":
popen_kwargs["start_new_session"] = True
elif os.name == "nt":
# New process group so the npx → node → next subtree can be killed as a
# unit by taskkill /T in _stop_webui.
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
try:
webui_proc = subprocess.Popen(
[npx, "--yes", _WEBUI_PACKAGE, "--port", str(webui_port)],
**popen_kwargs,
)
except Exception as exc:
console.print(f"[red]Failed to launch WebUI via npx:[/red] {exc}")
raise typer.Exit(1) from exc
atexit.register(_stop_webui, webui_proc)
# 6. Block on signal — same dual-gate as `EvoSci deploy` (threading.Event +
# explicit SIGINT/SIGTERM handlers). Also exit if the front-end dies on its
# own (e.g. the user closes it), so we don't leave the backend orphaned.
shutdown_event = threading.Event()
def _handle_shutdown(signum: int, _frame: Any) -> None:
shutdown_event.set()
if signum == signal.SIGINT:
signal.default_int_handler(signum, _frame)
_orig_sigint = signal.signal(signal.SIGINT, _handle_shutdown)
_orig_sigterm = signal.signal(signal.SIGTERM, _handle_shutdown)
try:
while not shutdown_event.is_set():
if webui_proc.poll() is not None:
console.print("\n[dim]WebUI server exited.[/dim]")
break
shutdown_event.wait(timeout=0.5)
except KeyboardInterrupt:
shutdown_event.set()
finally:
signal.signal(signal.SIGINT, _orig_sigint)
signal.signal(signal.SIGTERM, _orig_sigterm)
_stop_webui(webui_proc)
# stop_langgraph_dev (if we started it) runs via atexit during
# interpreter shutdown — don't claim "Stopped." before that fires.
console.print(
"\n[dim]Shutting down (background cleanup may take a few seconds)...[/dim]"
)
def _stop_webui(proc: subprocess.Popen) -> None:
"""Terminate the WebUI process tree (idempotent)."""
if proc.poll() is not None:
return
try:
if os.name == "posix":
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
elif os.name == "nt":
# taskkill /T terminates the whole child tree (node + next server).
subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
def _scrubbed_env(extra: dict[str, str]) -> dict[str, str]:
"""Inherit the parent environment minus secrets, then apply ``extra``.
The WebUI is a browser client that only talks to the local langgraph server
— it has no use for LLM provider API keys. Stripping credential-bearing
variables keeps them out of the npx-fetched front-end package and its
transitive npm dependencies (defence-in-depth, especially with ``@latest``).
Names are matched loosely (``*_KEY`` / ``*API_KEY*`` / ``*TOKEN*`` /
``*SECRET*`` / ``*PASSWORD*``); node/npm essentials (PATH, HOME, NODE_*,
npm_*, proxies, CA certs) carry none of these and pass through untouched.
"""
secret_hints = ("API_KEY", "TOKEN", "SECRET", "PASSWORD")
env = {
k: v
for k, v in os.environ.items()
if not (
k.upper().endswith("_KEY")
or any(hint in k.upper() for hint in secret_hints)
)
}
env.update(extra)
return env
def _shorten(path: str) -> str:
"""Replace ``$HOME`` prefix with ``~`` for compact display."""
home = os.path.expanduser("~")
if path.startswith(home):
return "~" + path[len(home) :]
return path