fix(code): bind ephemeral port instead of squatting langgraph dev's 2024 (#4264)

`deepagents-code` no longer occupies port 2024, so you can run your own
`langgraph dev` projects alongside it without a port collision.

---

`deepagents-code`'s internal `langgraph dev` server defaulted to port
2024 — the same well-known default the `langgraph` CLI uses — so it
squatted that port and collided with users' own `langgraph dev`
projects. The server is internal/ephemeral (callers only reach it via
`ServerProcess.url`), so it now binds an OS-assigned free port by
default and leaves 2024 available. Explicit ports are still honored,
keeping the existing in-use fallback.

Made by [Open
SWE](https://openswe.vercel.app/agents/e1e18910-5c4f-7030-0c5f-cc8b2ed40d96)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-26 00:43:44 -04:00
committed by GitHub
parent 220dfc0e6b
commit 11e5359851
4 changed files with 127 additions and 19 deletions
+4 -3
View File
@@ -250,7 +250,7 @@
#### TB10: RemoteAgent → LangGraph Dev Server
- **Inside**: Server bound to `127.0.0.1` by default; `server.py:_DEFAULT_HOST = "127.0.0.1"`. `RemoteAgent` only connects to the URL returned by `ServerProcess.url`. Server is ephemeral — started at session start, stopped at session end. Port defaults to 2024 but auto-selects a free port if occupied.
- **Inside**: Server bound to `127.0.0.1` by default; `server.py:_DEFAULT_HOST = "127.0.0.1"`. `RemoteAgent` only connects to the URL returned by `ServerProcess.url`. Server is ephemeral — started at session start, stopped at session end. Binds a free ephemeral port by default (`server.py:_EPHEMERAL_PORT`); an explicit port is honored but still falls back to a free port if occupied.
- **Outside**: `LANGGRAPH_AUTH_TYPE=noop` disables all LangGraph server authentication. Any process on localhost that discovers the port can submit requests, read thread state, or inject messages.
- **Crossing mechanism**: HTTP POST/GET to `http://127.0.0.1:{port}` using `langgraph.pregel.remote.RemoteGraph`.
@@ -385,7 +385,7 @@
#### T6: Unauthenticated LangGraph Dev Server on Localhost
- **Flow**: DF3/DF4 (CLI ↔ LangGraph dev server)
- **Description**: The CLI spawns a `langgraph dev` server subprocess with `LANGGRAPH_AUTH_TYPE=noop` (`server.py:_build_server_env`). This disables all server-side authentication. The server binds to `127.0.0.1:{port}` (default port 2024, or a random free port if 2024 is occupied). Any local process that discovers the port can: send arbitrary inputs to the running agent thread, read the agent's conversation state (including tool results that may contain file contents or secrets), inject messages into the conversation history, or trigger state updates. The server is ephemeral — it lives only for the duration of the CLI session — but this is the entire attack window. Port discovery is feasible via localhost port scanning or by reading `/proc/{pid}/cmdline` which contains the `--port` argument.
- **Description**: The CLI spawns a `langgraph dev` server subprocess with `LANGGRAPH_AUTH_TYPE=noop` (`server.py:_build_server_env`). This disables all server-side authentication. The server binds to `127.0.0.1:{port}` (a free ephemeral port by default, so it no longer squats the well-known `langgraph dev` port 2024). Any local process that discovers the port can: send arbitrary inputs to the running agent thread, read the agent's conversation state (including tool results that may contain file contents or secrets), inject messages into the conversation history, or trigger state updates. The server is ephemeral — it lives only for the duration of the CLI session — but this is the entire attack window. Port discovery is feasible via localhost port scanning or by reading `/proc/{pid}/cmdline` which contains the `--port` argument.
- **Preconditions**: (1) Attacker has a local process running as the same user (or as root); (2) Attacker discovers the server port (port scan on localhost, or reads process arguments).
#### T7: LocalContextMiddleware Injects Host File Contents into System Prompt
@@ -459,7 +459,7 @@ Threats that appear valid in isolation but fall outside project responsibility b
| Sandbox provider security vulnerabilities | Daytona, LangSmith, Modal, Runloop, and AgentCore are third-party services. Their internal security is not this project's responsibility. | Correctly initializing sandbox sessions via `integrations.sandbox_factory.create_sandbox`. |
| Hook commands doing harmful things | Hooks in `~/.deepagents/hooks.json` are 100% user-authored. The payload is data-only (JSON on stdin). | JSON structure validation (`hooks._load_hooks`); 5-second timeout. |
| Async subagent traffic interception / MitM | Async subagents connect to user-configured LangGraph deployment URLs. The project does not control those endpoints or their TLS certificates. | Accepting URL/headers from user config and passing them to the LangGraph SDK (`agent.load_async_subagents`). |
| LangGraph dev server port enumeration / discovery | Discovering the local dev server port requires local access. Port scanning localhost is a general OS security concern, not a framework vulnerability. | Binding to `127.0.0.1` by default (`server._DEFAULT_HOST`); ephemeral server lifetime. |
| LangGraph dev server port enumeration / discovery | Discovering the local dev server port requires local access. Port scanning localhost is a general OS security concern, not a framework vulnerability. | Binding to `127.0.0.1` by default (`server._DEFAULT_HOST`); ephemeral server lifetime; OS-assigned ephemeral port (`server._EPHEMERAL_PORT`) is not predictable across runs. |
| `.env` file from parent directory changes app/API configuration | `config._find_dotenv_from_start_path` walks up the directory tree to find `.env` files. Discovering ordinary configuration values (API keys, `DEEPAGENTS_CODE_*` settings) this way is standard `python-dotenv` behavior, and the user controls their filesystem. The *code-execution* implication of a project `.env` (shell startup hooks) is tracked in-scope as T12. | Finding `.env` from the project root (`config._find_dotenv_from_start_path`); `override=False` by default (existing env vars preserved); shell startup / environment-hijack keys (`BASH_ENV`, `ENV`) denied during dotenv loading. |
### Rationale
@@ -493,3 +493,4 @@ Threats that appear valid in isolation but fall outside project responsibility b
| 2026-03-27 | langster-threat-model (automated) | Deep expansion: added C11-C16 (server subprocess, RemoteAgent, LocalContextMiddleware, async subagent config, custom subagent loader); added TB8-TB10 (CLI/server IPC, LocalContext/host env, RemoteAgent/dev server); added DF18-DF22; added T6 (unauthenticated dev server), T7 (Makefile injection), T8 (subagent body injection); updated T5 (upstream msgpack fix confirmed); added data classification; added Investigated and Dismissed section; updated architecture diagram to reflect server-subprocess model |
| 2026-03-28 | langster-threat-model (automated) | Deep validation pass: added C17 (Model Config Loader with class_path), TB11 (Config→Code Execution); added DC5 (offloaded conversation history); added DF23-DF25 (class_path flow, MCP env dict flow, offload flow); added T9 (class_path arbitrary code execution), T10 (MCP env dict unfiltered); added D3 (SSRF dismissed — HITL is intended control), D4 (offload path injection dismissed — UUID7); **removed Status column and Mitigations/Residual Risk fields from all threats** (open source visibility compliance — mitigation status must not appear in public threat models); updated T6 validation from Likely to Verified (port is deterministic at 2024 default, discoverable via /proc); updated external context (no published advisories found); updated architecture diagram |
| 2026-06-25 | manual update | Added T12 (project `.env` injects shell interpreter startup hooks) under TB11; extended the TB11 boundary controls and details with the dotenv shell-startup-hook denylist (`BASH_ENV`, `ENV`); clarified the `.env` out-of-scope row to separate ordinary config discovery from the in-scope code-execution implication |
| 2026-06-25 | manual update | Updated T6 and TB10 to reflect the dev server binding an OS-assigned ephemeral port by default (`server._EPHEMERAL_PORT`) instead of squatting the well-known `langgraph dev` port 2024; noted unpredictable-port-across-runs as added defense-in-depth in the port-discovery dismissal row |
+20 -8
View File
@@ -28,7 +28,14 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_DEFAULT_HOST = "127.0.0.1"
_DEFAULT_PORT = 2024
_EPHEMERAL_PORT = 0
"""Sentinel port meaning "let `start()` pick a free ephemeral port".
The server is internal and ephemeral — callers reach it via `ServerProcess.url`,
never a typed-in address — so it deliberately avoids binding the well-known
`langgraph dev` default (2024). Leaving 2024 free lets users run their own
`langgraph dev` projects alongside `deepagents-code` without a port collision.
"""
_HEALTH_POLL_INTERVAL_LOCAL = 0.1
_HEALTH_POLL_INTERVAL_REMOTE = 0.3
_HEALTH_TIMEOUT = 60
@@ -105,7 +112,7 @@ def _find_free_port(host: str) -> int:
return s.getsockname()[1]
def get_server_url(host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> str:
def get_server_url(host: str = _DEFAULT_HOST, port: int = _EPHEMERAL_PORT) -> str:
"""Build the server base URL.
Args:
@@ -366,7 +373,7 @@ class ServerProcess:
self,
*,
host: str = _DEFAULT_HOST,
port: int = _DEFAULT_PORT,
port: int = _EPHEMERAL_PORT,
config_dir: str | Path | None = None,
owns_config_dir: bool = False,
scaffold: Callable[[Path], None] | None = None,
@@ -375,10 +382,12 @@ class ServerProcess:
Args:
host: Host to bind the server to.
port: Initial port to bind the server to.
port: Initial port to bind the server to. Defaults to
`_EPHEMERAL_PORT` (0), so `start()` picks a free port and avoids
squatting the well-known `langgraph dev` default (2024).
May be reassigned automatically by `start()` if the port is
already in use.
An explicit port is honored, but `start()` still falls back to a
free port if it is already in use.
config_dir: Directory containing `langgraph.json`.
owns_config_dir: When `True`, the server will delete `config_dir`
on `stop()`.
@@ -484,9 +493,12 @@ class ServerProcess:
)
raise RuntimeError(msg)
if _port_in_use(self.host, self.port):
if self.port == _EPHEMERAL_PORT:
self.port = _find_free_port(self.host)
logger.info("Default port in use, using port %d instead", self.port)
logger.info("Using ephemeral port %d for langgraph dev server", self.port)
elif _port_in_use(self.host, self.port):
self.port = _find_free_port(self.host)
logger.info("Requested port in use, using port %d instead", self.port)
cmd = _build_server_cmd(config_path, host=self.host, port=self.port)
env = _build_server_env()
+9 -4
View File
@@ -34,6 +34,7 @@ if TYPE_CHECKING:
from deepagents_code._env_vars import SERVER_ENV_PREFIX
from deepagents_code._server_config import ServerConfig
from deepagents_code.project_utils import ProjectContext
from deepagents_code.server import _EPHEMERAL_PORT
logger = logging.getLogger(__name__)
_DISTRIBUTION_NAME = "deepagents-code"
@@ -289,7 +290,7 @@ async def start_server_and_get_agent(
trust_project_mcp: bool | None = None,
interactive: bool = True,
host: str = "127.0.0.1",
port: int = 2024,
port: int = _EPHEMERAL_PORT,
) -> tuple[RemoteAgent, ServerProcess, MCPSessionManager | None]:
"""Start a LangGraph server and return a connected remote agent client.
@@ -316,7 +317,9 @@ async def start_server_and_get_agent(
trust_project_mcp: Trust project MCP servers.
interactive: Whether the agent is interactive.
host: Server host.
port: Server port.
port: Server port. Defaults to `_EPHEMERAL_PORT` (0), letting the server
pick a free ephemeral port instead of the well-known `langgraph dev`
port 2024.
Returns:
Tuple of `(remote_agent, server_process, mcp_session_manager)`.
@@ -415,7 +418,7 @@ async def server_session(
trust_project_mcp: bool | None = None,
interactive: bool = True,
host: str = "127.0.0.1",
port: int = 2024,
port: int = _EPHEMERAL_PORT,
) -> AsyncIterator[tuple[RemoteAgent, ServerProcess]]:
"""Async context manager that starts a server and guarantees cleanup.
@@ -445,7 +448,9 @@ async def server_session(
trust_project_mcp: Trust project MCP servers.
interactive: Whether the agent is interactive.
host: Server host.
port: Server port.
port: Server port. Defaults to `_EPHEMERAL_PORT` (0), letting the server
pick a free ephemeral port instead of the well-known `langgraph dev`
port 2024.
Yields:
Tuple of `(remote_agent, server_process)`.
+94 -4
View File
@@ -117,6 +117,94 @@ class TestFindFreePort:
assert port == 53123
class TestServerPortSelection:
"""Port resolution in `ServerProcess.start()`."""
@staticmethod
def _make_server(tmp_path: Path, port: int = 0) -> ServerProcess:
config_dir = tmp_path / "runtime"
config_dir.mkdir()
(config_dir / "langgraph.json").write_text("{}")
return ServerProcess(config_dir=config_dir, port=port)
@staticmethod
def _mock_log_file(tmp_path: Path) -> MagicMock:
log_file = MagicMock()
log_file.name = str(tmp_path / "server.log")
return log_file
async def test_default_uses_ephemeral_port(self, tmp_path: Path) -> None:
"""Default port (0) resolves via `_find_free_port`, never squats 2024."""
server = self._make_server(tmp_path)
assert server.port == 0
process = MagicMock(pid=1234)
process.poll.return_value = None
with (
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
return_value=self._mock_log_file(tmp_path),
),
patch("deepagents_code.server.subprocess.Popen", return_value=process),
patch("deepagents_code.server.wait_for_server_healthy", new=AsyncMock()),
patch(
"deepagents_code.server._find_free_port", return_value=43210
) as find_free,
patch("deepagents_code.server._port_in_use") as in_use,
):
await server.start()
find_free.assert_called_once_with("127.0.0.1")
in_use.assert_not_called()
assert server.port == 43210
async def test_explicit_free_port_is_kept(self, tmp_path: Path) -> None:
"""An explicit, free port is honored without searching for another."""
server = self._make_server(tmp_path, port=2024)
process = MagicMock(pid=1234)
process.poll.return_value = None
with (
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
return_value=self._mock_log_file(tmp_path),
),
patch("deepagents_code.server.subprocess.Popen", return_value=process),
patch("deepagents_code.server.wait_for_server_healthy", new=AsyncMock()),
patch("deepagents_code.server._port_in_use", return_value=False) as in_use,
patch("deepagents_code.server._find_free_port") as find_free,
):
await server.start()
in_use.assert_called_once_with("127.0.0.1", 2024)
find_free.assert_not_called()
assert server.port == 2024
async def test_explicit_busy_port_falls_back(self, tmp_path: Path) -> None:
"""An explicit but busy port falls back to a free port."""
server = self._make_server(tmp_path, port=2024)
process = MagicMock(pid=1234)
process.poll.return_value = None
with (
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
return_value=self._mock_log_file(tmp_path),
),
patch("deepagents_code.server.subprocess.Popen", return_value=process),
patch("deepagents_code.server.wait_for_server_healthy", new=AsyncMock()),
patch("deepagents_code.server._port_in_use", return_value=True) as in_use,
patch(
"deepagents_code.server._find_free_port", return_value=43210
) as find_free,
):
await server.start()
in_use.assert_called_once_with("127.0.0.1", 2024)
find_free.assert_called_once_with("127.0.0.1")
assert server.port == 43210
class TestWaitForServerHealthy:
"""Tests for the health-check polling loop."""
@@ -316,7 +404,7 @@ class TestServerProcess:
server = ServerProcess(config_dir=config_dir, owns_config_dir=True)
with (
patch("deepagents_code.server._port_in_use", return_value=False),
patch("deepagents_code.server._find_free_port", return_value=12345),
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
return_value=log_file,
@@ -358,7 +446,7 @@ class TestServerProcess:
server = ServerProcess(config_dir=config_dir, scaffold=scaffold_mock)
with (
patch("deepagents_code.server._port_in_use", return_value=False),
patch("deepagents_code.server._find_free_port", return_value=12345),
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
return_value=log_file,
@@ -445,7 +533,7 @@ class TestServerProcess:
server = ServerProcess(config_dir=config_dir, scaffold=scaffold_mock)
with (
patch("deepagents_code.server._port_in_use", return_value=False),
patch("deepagents_code.server._find_free_port", return_value=12345),
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
return_value=log_file,
@@ -482,7 +570,7 @@ class TestServerProcess:
server = ServerProcess(config_dir=config_dir, scaffold=scaffold_mock)
with (
patch("deepagents_code.server._port_in_use", return_value=False),
patch("deepagents_code.server._find_free_port", return_value=12345),
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
return_value=log_file,
@@ -526,6 +614,7 @@ class TestServerProcess:
server = ServerProcess(config_dir=config_dir, scaffold=scaffold_mock)
with (
patch("deepagents_code.server._find_free_port", return_value=12345),
patch("deepagents_code.server._port_in_use", return_value=False),
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",
@@ -568,6 +657,7 @@ class TestServerProcess:
server = ServerProcess(config_dir=config_dir, owns_config_dir=False)
with (
patch("deepagents_code.server._find_free_port", return_value=12345),
patch("deepagents_code.server._port_in_use", return_value=False),
patch(
"deepagents_code.server.tempfile.NamedTemporaryFile",