3c5cc831c0
* 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>
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""Tests for ``start_langgraph_dev(deploy_mode=...)`` env var injection.
|
|
|
|
Verifies the single-env-var enum routing:
|
|
- ``deploy_mode=True`` → ``EVOSCIENTIST_DEPLOY_MODE=full``
|
|
- ``deploy_mode=False`` → ``EVOSCIENTIST_DEPLOY_MODE=stripped``
|
|
- (parent process / plain import) → ``EVOSCIENTIST_DEPLOY_MODE`` unset
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from EvoScientist.langgraph_dev import manager
|
|
|
|
|
|
class _PopenAbort(Exception):
|
|
"""Raised by the fake ``Popen`` to short-circuit ``start_langgraph_dev``
|
|
after the env dict is constructed but before health-polling runs."""
|
|
|
|
|
|
def _patch_start_prereqs(monkeypatch, tmp_path: Path, runtime_paths) -> dict:
|
|
"""Mock everything ``start_langgraph_dev`` does before ``subprocess.Popen``
|
|
so we can run it end-to-end up to the point where the env dict is captured.
|
|
Returns a ``captured`` dict that the test populates from the fake Popen."""
|
|
captured: dict = {}
|
|
|
|
monkeypatch.setattr(manager, "_langgraph_exe", lambda: "/usr/bin/langgraph")
|
|
|
|
fake_config = tmp_path / "langgraph.json"
|
|
fake_config.write_text("{}")
|
|
monkeypatch.setattr(manager, "_packaged_langgraph_config", lambda: fake_config)
|
|
|
|
# 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, *_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, *_a, **_kw: True
|
|
)
|
|
|
|
# Redirect the log file — pid_dir already rooted under tmp via the fixture.
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"RUNTIME",
|
|
dataclasses.replace(runtime_paths, log_file=tmp_path / "langgraph_dev.log"),
|
|
)
|
|
|
|
def _fake_popen(args, **kwargs):
|
|
captured["args"] = args
|
|
captured["env"] = kwargs.get("env", {})
|
|
captured["cwd"] = kwargs.get("cwd")
|
|
raise _PopenAbort("env captured")
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", _fake_popen)
|
|
return captured
|
|
|
|
|
|
def test_deploy_mode_true_sets_full(monkeypatch, tmp_path, runtime_paths):
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16174,
|
|
deploy_mode=True,
|
|
)
|
|
|
|
env = captured["env"]
|
|
assert env.get("EVOSCIENTIST_DEPLOY_MODE") == "full", (
|
|
"deploy_mode=True must inject EVOSCIENTIST_DEPLOY_MODE=full"
|
|
)
|
|
|
|
|
|
def test_deploy_mode_false_default_sets_stripped(monkeypatch, tmp_path, runtime_paths):
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
# deploy_mode omitted → defaults to False
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16175,
|
|
)
|
|
|
|
env = captured["env"]
|
|
assert env.get("EVOSCIENTIST_DEPLOY_MODE") == "stripped", (
|
|
"deploy_mode=False (default) must inject EVOSCIENTIST_DEPLOY_MODE=stripped"
|
|
)
|
|
|
|
|
|
def test_deploy_mode_explicitly_false_sets_stripped(
|
|
monkeypatch, tmp_path, runtime_paths
|
|
):
|
|
"""Same as default, but with deploy_mode=False stated explicitly."""
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16176,
|
|
deploy_mode=False,
|
|
)
|
|
|
|
env = captured["env"]
|
|
assert env.get("EVOSCIENTIST_DEPLOY_MODE") == "stripped"
|
|
|
|
|
|
def test_deploy_mode_always_set_to_one_of_full_or_stripped(
|
|
monkeypatch, tmp_path, runtime_paths
|
|
):
|
|
"""Regression: the subprocess always sees exactly one of the two enum
|
|
values for ``EVOSCIENTIST_DEPLOY_MODE`` — never unset, never garbage."""
|
|
for deploy_mode, expected in ((True, "full"), (False, "stripped")):
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16177,
|
|
deploy_mode=deploy_mode,
|
|
)
|
|
env = captured["env"]
|
|
assert env.get("EVOSCIENTIST_DEPLOY_MODE") == expected, (
|
|
f"deploy_mode={deploy_mode}: expected EVOSCIENTIST_DEPLOY_MODE="
|
|
f"{expected!r}, got {env.get('EVOSCIENTIST_DEPLOY_MODE')!r}"
|
|
)
|
|
|
|
|
|
def test_inherited_stripped_overridden_when_deploy_mode_true(
|
|
monkeypatch, tmp_path, runtime_paths
|
|
):
|
|
"""If the parent process exports ``EVOSCIENTIST_DEPLOY_MODE=stripped`` and
|
|
we ask for deploy mode, the subprocess env must see the resolved value
|
|
(``full``), not the stale inherited one."""
|
|
monkeypatch.setenv("EVOSCIENTIST_DEPLOY_MODE", "stripped")
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16180,
|
|
deploy_mode=True,
|
|
)
|
|
|
|
env = captured["env"]
|
|
assert env.get("EVOSCIENTIST_DEPLOY_MODE") == "full", (
|
|
"inherited stripped value must be overridden when deploy_mode=True"
|
|
)
|
|
|
|
|
|
def test_inherited_full_overridden_when_deploy_mode_false(
|
|
monkeypatch, tmp_path, runtime_paths
|
|
):
|
|
"""Symmetric: parent exports ``EVOSCIENTIST_DEPLOY_MODE=full``, CLI/serve
|
|
calls start_langgraph_dev with default (deploy_mode=False), inherited
|
|
value must be overridden to ``stripped``."""
|
|
monkeypatch.setenv("EVOSCIENTIST_DEPLOY_MODE", "full")
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16181,
|
|
)
|
|
|
|
env = captured["env"]
|
|
assert env.get("EVOSCIENTIST_DEPLOY_MODE") == "stripped", (
|
|
"inherited full value must be overridden when deploy_mode=False"
|
|
)
|
|
|
|
|
|
def test_inherited_arbitrary_value_overridden(monkeypatch, tmp_path, runtime_paths):
|
|
"""Defense against an unexpected inherited value (e.g. legacy ``true``
|
|
from before the enum rename, or any user-set garbage). The resolved
|
|
deploy_mode always wins."""
|
|
for inherited in ("true", "garbage", "FULL", ""):
|
|
for deploy_mode, expected in ((True, "full"), (False, "stripped")):
|
|
monkeypatch.setenv("EVOSCIENTIST_DEPLOY_MODE", inherited)
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16182,
|
|
deploy_mode=deploy_mode,
|
|
)
|
|
|
|
env = captured["env"]
|
|
assert env.get("EVOSCIENTIST_DEPLOY_MODE") == expected, (
|
|
f"inherited={inherited!r}, deploy_mode={deploy_mode}: "
|
|
f"expected EVOSCIENTIST_DEPLOY_MODE={expected!r}, "
|
|
f"got {env.get('EVOSCIENTIST_DEPLOY_MODE')!r}"
|
|
)
|
|
|
|
|
|
def test_workspace_dir_env_var_set_regardless_of_mode(
|
|
monkeypatch, tmp_path, runtime_paths
|
|
):
|
|
"""EVOSCIENTIST_WORKSPACE_DIR is independent of deploy_mode."""
|
|
for deploy_mode in (True, False):
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16178,
|
|
deploy_mode=deploy_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
|
|
# =============================================================================
|
|
|
|
|
|
def test_async_subagents_available_init_from_env_full(monkeypatch):
|
|
"""When ``EVOSCIENTIST_DEPLOY_MODE=full`` is set in the env at module
|
|
import time, ``_ASYNC_SUBAGENTS_AVAILABLE`` initializes to True so the
|
|
deployed main agent's ``_maybe_swap_async_subagents`` swaps eagerly
|
|
without waiting for ``start_langgraph_dev`` to flip the flag (which it
|
|
can't — the deploy subprocess never calls that function on itself)."""
|
|
monkeypatch.setenv("EVOSCIENTIST_DEPLOY_MODE", "full")
|
|
# Re-import the module to re-run the module-level initialization.
|
|
import importlib
|
|
|
|
import EvoScientist.langgraph_dev.manager as mgr
|
|
|
|
reloaded = importlib.reload(mgr)
|
|
try:
|
|
assert reloaded._ASYNC_SUBAGENTS_AVAILABLE is True
|
|
assert reloaded.is_async_subagents_available() is True
|
|
finally:
|
|
# Restore: reload again without the env var so subsequent tests
|
|
# see the normal initialization.
|
|
monkeypatch.delenv("EVOSCIENTIST_DEPLOY_MODE", raising=False)
|
|
importlib.reload(mgr)
|
|
|
|
|
|
def test_async_subagents_available_init_false_for_stripped(monkeypatch):
|
|
"""``stripped`` is the CLI/serve subprocess mode — async sub-agents stay
|
|
disabled at module-load time (they get enabled later by ``ensure_langgraph_dev``
|
|
in the parent process, NOT by the subprocess flipping its own flag)."""
|
|
monkeypatch.setenv("EVOSCIENTIST_DEPLOY_MODE", "stripped")
|
|
import importlib
|
|
|
|
import EvoScientist.langgraph_dev.manager as mgr
|
|
|
|
reloaded = importlib.reload(mgr)
|
|
try:
|
|
assert reloaded._ASYNC_SUBAGENTS_AVAILABLE is False
|
|
finally:
|
|
monkeypatch.delenv("EVOSCIENTIST_DEPLOY_MODE", raising=False)
|
|
importlib.reload(mgr)
|
|
|
|
|
|
def test_async_subagents_available_init_false_without_env(monkeypatch):
|
|
"""When the env var is unset, ``_ASYNC_SUBAGENTS_AVAILABLE`` initializes
|
|
to False — the pre-existing safety behavior (fall back to sync if
|
|
langgraph dev isn't reachable)."""
|
|
monkeypatch.delenv("EVOSCIENTIST_DEPLOY_MODE", raising=False)
|
|
import importlib
|
|
|
|
import EvoScientist.langgraph_dev.manager as mgr
|
|
|
|
reloaded = importlib.reload(mgr)
|
|
assert reloaded._ASYNC_SUBAGENTS_AVAILABLE is False
|
|
|
|
|
|
def test_tunnel_true_appends_flag(monkeypatch, tmp_path, runtime_paths):
|
|
"""``tunnel=True`` must add ``--tunnel`` to the langgraph dev argv."""
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16190,
|
|
tunnel=True,
|
|
)
|
|
|
|
assert "--tunnel" in captured["args"]
|
|
|
|
|
|
def test_tunnel_false_default_omits_flag(monkeypatch, tmp_path, runtime_paths):
|
|
"""``tunnel`` defaults to False — no ``--tunnel`` in the argv."""
|
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
|
|
|
with pytest.raises(_PopenAbort):
|
|
manager.start_langgraph_dev(
|
|
workspace_dir=tmp_path,
|
|
port=16191,
|
|
)
|
|
|
|
assert "--tunnel" not in captured["args"]
|