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>
This commit is contained in:
Xiaohui Yan
2026-08-06 16:15:49 +08:00
committed by GitHub
parent a9a57cd828
commit 3c5cc831c0
21 changed files with 1204 additions and 70 deletions
+3 -1
View File
@@ -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"
+5 -1
View File
@@ -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"
+3 -1
View File
@@ -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"
+7 -2
View File
@@ -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: <name>`` 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)
+43 -1
View File
@@ -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:
+16 -2
View File
@@ -156,8 +156,14 @@ def _step_langgraph_dev_port(config: EvoScientistConfig) -> int:
f"EvoSci config set langgraph_dev_port <other-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]"
+30
View File
@@ -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",
+37 -3
View File
@@ -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 / "
+48 -6
View File
@@ -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://<this-machine-ip>:{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":
+101 -29
View File
@@ -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 <other-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,
)
+28 -3
View File
@@ -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:
+17
View File
@@ -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://<this-machine-ip>: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://<this-machine-ip>: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 <host>`.
</details>
<details>
+17
View File
@@ -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 <主机>`。
</details>
<details>
+44 -3
View File
@@ -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"
# =============================================================================
+127 -5
View File
@@ -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
+107 -5
View File
@@ -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,
):
+61
View File
@@ -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
):
+57 -4
View File
@@ -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
# =============================================================================
+129 -4
View File
@@ -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")
+53
View File
@@ -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."""
+271
View File
@@ -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 ``<rich.panel.Panel object at ...>`` 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