From d5d65286a6c83385f90a7c2c2db8bda7c51b4afe Mon Sep 17 00:00:00 2001 From: David Tomaschik Date: Fri, 1 May 2026 16:04:17 -0700 Subject: [PATCH 1/3] Add --bind flag for server --- openhands_cli/argparsers/serve_parser.py | 55 ++++++++++++++++++++++ openhands_cli/entrypoint.py | 4 +- openhands_cli/gui_launcher.py | 21 +++++++-- tests/test_gui_launcher.py | 59 +++++++++++++++++++++--- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/openhands_cli/argparsers/serve_parser.py b/openhands_cli/argparsers/serve_parser.py index 9ca2e8f4d..75e5d9413 100644 --- a/openhands_cli/argparsers/serve_parser.py +++ b/openhands_cli/argparsers/serve_parser.py @@ -1,6 +1,55 @@ """Argument parser for serve subcommand.""" import argparse +import ipaddress +from urllib.parse import urlsplit + + +def validate_bind_address(value: str) -> str: + """Validate that the bind address is a valid IP or IP:port combination. + + Supports: + - IPv4 (e.g., 127.0.0.1) + - IPv4:port (e.g., 127.0.0.1:3000) + - IPv6 (e.g., ::1) + - [IPv6]:port (e.g., [::1]:3000) + + Args: + value: The string to validate + + Returns: + The validated string + + Raises: + argparse.ArgumentTypeError: If the value is invalid + """ + if not value: + raise argparse.ArgumentTypeError("Bind address cannot be empty") + + try: + # urlsplit requires a scheme-like start to parse netloc correctly + # We use // as a prefix to treat it as a network location + parts = urlsplit(f"//{value}") + host = parts.hostname + port = parts.port + + if not host: + raise ValueError("Could not parse host from bind address") + + # Validate IP + ipaddress.ip_address(host) + + # Validate Port if present + if port is not None: + if not (1 <= port <= 65535): + raise ValueError(f"Port {port} out of range") + + return value + except ValueError as e: + raise argparse.ArgumentTypeError( + f"Invalid bind address: '{value}'. {str(e)}. " + "Expected IP or IP:port (e.g., 127.0.0.1:3000 or [::1]:3000)" + ) def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: @@ -28,4 +77,10 @@ def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.Argumen action="store_true", default=False, ) + serve_parser.add_argument( + "--bind", + help="Bind the GUI server to a specific IP or IP:port (e.g., 127.0.0.1 or 127.0.0.1:3000)", + type=validate_bind_address, + default="127.0.0.1:3000", + ) return serve_parser diff --git a/openhands_cli/entrypoint.py b/openhands_cli/entrypoint.py index 479d311ec..adb0ff955 100644 --- a/openhands_cli/entrypoint.py +++ b/openhands_cli/entrypoint.py @@ -123,7 +123,9 @@ def main() -> None: # Import gui_launcher only when needed from openhands_cli.gui_launcher import launch_gui_server - launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu) + launch_gui_server( + mount_cwd=args.mount_cwd, gpu=args.gpu, bind_address=args.bind + ) elif args.command == "web": # Import web server launcher only when needed from openhands_cli.tui.serve import launch_web_server diff --git a/openhands_cli/gui_launcher.py b/openhands_cli/gui_launcher.py index b2b61e8ff..d3a8c73d0 100644 --- a/openhands_cli/gui_launcher.py +++ b/openhands_cli/gui_launcher.py @@ -5,6 +5,7 @@ import subprocess import sys from pathlib import Path +from urllib.parse import urlsplit from rich.console import Console from rich.markup import escape @@ -88,13 +89,16 @@ def get_openhands_version() -> str: return os.environ.get("OPENHANDS_VERSION", "latest") -def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None: +def launch_gui_server( + mount_cwd: bool = False, gpu: bool = False, bind_address: str = "127.0.0.1:3000" +) -> None: """Launch the OpenHands GUI server using Docker. Args: mount_cwd: If True, mount the current working directory into the container. gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker. + bind_address: The IP address or IP:port to bind the server to. """ console.print("🚀 Launching OpenHands GUI server...", style="blue", markup=False) console.print() @@ -115,9 +119,20 @@ def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None: # tested and compatible with that specific app version. Setting these env vars # could cause version mismatches between the app and agent server. + # Parse bind address to get host IP and port using urllib.parse.urlsplit + parts = urlsplit(f"//{bind_address}") + host_ip = parts.hostname or "127.0.0.1" + host_port = str(parts.port or "3000") + + # If it's an IPv6 address, we need to wrap it in brackets for the Docker port mapping + # and the URL display, but only if it's not already bracketed. + # urlsplit.hostname returns the IP without brackets. + if ":" in host_ip and not host_ip.startswith("["): + host_ip = f"[{host_ip}]" + console.print("✅ Starting OpenHands GUI server...", style="green", markup=False) console.print( - "The server will be available at: http://localhost:3000", + f"The server will be available at: http://{host_ip}:{host_port}", style="grey50", markup=False, ) @@ -187,7 +202,7 @@ def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None: docker_cmd.extend( [ "-p", - "3000:3000", + f"{host_ip}:{host_port}:3000", "--add-host", "host.docker.internal:host-gateway", "--name", diff --git a/tests/test_gui_launcher.py b/tests/test_gui_launcher.py index 2b312ec6f..2e71e9d85 100644 --- a/tests/test_gui_launcher.py +++ b/tests/test_gui_launcher.py @@ -118,16 +118,52 @@ def test_launch_gui_server_docker_not_available( assert exc_info.value.code == 1 @pytest.mark.parametrize( - "run_side_effect,expected_exit_code,mount_cwd,gpu", + "run_side_effect,expected_exit_code,mount_cwd,gpu,bind_address,expected_port_map", [ # Docker run failure - (subprocess.CalledProcessError(1, "docker run"), 1, False, False), + ( + subprocess.CalledProcessError(1, "docker run"), + 1, + False, + False, + "127.0.0.1:3000", + "127.0.0.1:3000:3000", + ), # KeyboardInterrupt during run - (KeyboardInterrupt(), 0, False, False), + (KeyboardInterrupt(), 0, False, False, "127.0.0.1:3000", "127.0.0.1:3000:3000"), # Success with mount_cwd - (MagicMock(returncode=0), None, True, False), + (MagicMock(returncode=0), None, True, False, "127.0.0.1:3000", "127.0.0.1:3000:3000"), # Success with GPU - (MagicMock(returncode=0), None, False, True), + (MagicMock(returncode=0), None, False, True, "127.0.0.1:3000", "127.0.0.1:3000:3000"), + # Success with custom bind IP + (MagicMock(returncode=0), None, False, False, "0.0.0.0", "0.0.0.0:3000:3000"), + # Success with custom bind IP:port + ( + MagicMock(returncode=0), + None, + False, + False, + "192.168.1.100:8080", + "192.168.1.100:8080:3000", + ), + # Success with bare IPv6 + ( + MagicMock(returncode=0), + None, + False, + False, + "::1", + "::1:3000:3000", + ), + # Success with bracketed IPv6:port + ( + MagicMock(returncode=0), + None, + False, + False, + "[::1]:8080", + "[::1]:8080:3000", + ), ], ) @patch("openhands_cli.gui_launcher.check_docker_requirements") @@ -150,6 +186,8 @@ def test_launch_gui_server_scenarios( expected_exit_code, mount_cwd, gpu, + bind_address, + expected_port_map, ): """Test various GUI server launch scenarios.""" # Setup mocks @@ -168,11 +206,13 @@ def test_launch_gui_server_scenarios( # Test the function if expected_exit_code is not None: with pytest.raises(SystemExit) as exc_info: - launch_gui_server(mount_cwd=mount_cwd, gpu=gpu) + launch_gui_server( + mount_cwd=mount_cwd, gpu=gpu, bind_address=bind_address + ) assert exc_info.value.code == expected_exit_code else: # Should not raise SystemExit for successful cases - launch_gui_server(mount_cwd=mount_cwd, gpu=gpu) + launch_gui_server(mount_cwd=mount_cwd, gpu=gpu, bind_address=bind_address) # Verify subprocess.run was called once (only docker run, no separate pull) assert mock_run.call_count == 1 @@ -184,6 +224,11 @@ def test_launch_gui_server_scenarios( # Verify --pull=always is in the command assert "--pull=always" in run_cmd + # Verify port mapping + assert "-p" in run_cmd + port_index = run_cmd.index("-p") + assert run_cmd[port_index + 1] == expected_port_map + if mount_cwd: assert "SANDBOX_VOLUMES=/current/dir:/workspace:rw" in " ".join(run_cmd) assert "SANDBOX_USER_ID=1000" in " ".join(run_cmd) From 5b119dc13afb77e8eb347a997020f558a1e764f1 Mon Sep 17 00:00:00 2001 From: David Tomaschik Date: Fri, 1 May 2026 16:54:53 -0700 Subject: [PATCH 2/3] Refine parsing logic --- openhands_cli/argparsers/serve_parser.py | 27 +++++++++++++++--------- openhands_cli/gui_launcher.py | 14 ++++++------ tests/test_gui_launcher.py | 18 ++++++++-------- tests/test_main.py | 17 +++++++++++---- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/openhands_cli/argparsers/serve_parser.py b/openhands_cli/argparsers/serve_parser.py index 75e5d9413..843613376 100644 --- a/openhands_cli/argparsers/serve_parser.py +++ b/openhands_cli/argparsers/serve_parser.py @@ -5,8 +5,8 @@ from urllib.parse import urlsplit -def validate_bind_address(value: str) -> str: - """Validate that the bind address is a valid IP or IP:port combination. +def parse_bind_address(value: str) -> tuple[str, int]: + """Parse and validate that the bind address is a valid IP or IP:port combination. Supports: - IPv4 (e.g., 127.0.0.1) @@ -18,7 +18,7 @@ def validate_bind_address(value: str) -> str: value: The string to validate Returns: - The validated string + A tuple of (host, port) Raises: argparse.ArgumentTypeError: If the value is invalid @@ -26,12 +26,20 @@ def validate_bind_address(value: str) -> str: if not value: raise argparse.ArgumentTypeError("Bind address cannot be empty") + # First, try to parse as a bare IP address (no port) + try: + ipaddress.ip_address(value) + return value, 3000 + except ValueError: + # Not a bare IP, might be IP:port or [IPv6]:port + pass + try: # urlsplit requires a scheme-like start to parse netloc correctly # We use // as a prefix to treat it as a network location parts = urlsplit(f"//{value}") host = parts.hostname - port = parts.port + port = parts.port or 3000 if not host: raise ValueError("Could not parse host from bind address") @@ -39,12 +47,11 @@ def validate_bind_address(value: str) -> str: # Validate IP ipaddress.ip_address(host) - # Validate Port if present - if port is not None: - if not (1 <= port <= 65535): - raise ValueError(f"Port {port} out of range") + # Validate Port + if not (1 <= port <= 65535): + raise ValueError(f"Port {port} out of range") - return value + return host, port except ValueError as e: raise argparse.ArgumentTypeError( f"Invalid bind address: '{value}'. {str(e)}. " @@ -80,7 +87,7 @@ def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.Argumen serve_parser.add_argument( "--bind", help="Bind the GUI server to a specific IP or IP:port (e.g., 127.0.0.1 or 127.0.0.1:3000)", - type=validate_bind_address, + type=parse_bind_address, default="127.0.0.1:3000", ) return serve_parser diff --git a/openhands_cli/gui_launcher.py b/openhands_cli/gui_launcher.py index d3a8c73d0..3eb5f4d3b 100644 --- a/openhands_cli/gui_launcher.py +++ b/openhands_cli/gui_launcher.py @@ -90,7 +90,9 @@ def get_openhands_version() -> str: def launch_gui_server( - mount_cwd: bool = False, gpu: bool = False, bind_address: str = "127.0.0.1:3000" + mount_cwd: bool = False, + gpu: bool = False, + bind_address: tuple[str, int] = ("127.0.0.1", 3000), ) -> None: """Launch the OpenHands GUI server using Docker. @@ -98,7 +100,7 @@ def launch_gui_server( mount_cwd: If True, mount the current working directory into the container. gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker. - bind_address: The IP address or IP:port to bind the server to. + bind_address: A tuple of (host, port) to bind the server to. """ console.print("🚀 Launching OpenHands GUI server...", style="blue", markup=False) console.print() @@ -119,14 +121,12 @@ def launch_gui_server( # tested and compatible with that specific app version. Setting these env vars # could cause version mismatches between the app and agent server. - # Parse bind address to get host IP and port using urllib.parse.urlsplit - parts = urlsplit(f"//{bind_address}") - host_ip = parts.hostname or "127.0.0.1" - host_port = str(parts.port or "3000") + # Extract host IP and port from bind_address tuple + host_ip, host_port_int = bind_address + host_port = str(host_port_int) # If it's an IPv6 address, we need to wrap it in brackets for the Docker port mapping # and the URL display, but only if it's not already bracketed. - # urlsplit.hostname returns the IP without brackets. if ":" in host_ip and not host_ip.startswith("["): host_ip = f"[{host_ip}]" diff --git a/tests/test_gui_launcher.py b/tests/test_gui_launcher.py index 2e71e9d85..09c1ba7e5 100644 --- a/tests/test_gui_launcher.py +++ b/tests/test_gui_launcher.py @@ -126,24 +126,24 @@ def test_launch_gui_server_docker_not_available( 1, False, False, - "127.0.0.1:3000", + ("127.0.0.1", 3000), "127.0.0.1:3000:3000", ), # KeyboardInterrupt during run - (KeyboardInterrupt(), 0, False, False, "127.0.0.1:3000", "127.0.0.1:3000:3000"), + (KeyboardInterrupt(), 0, False, False, ("127.0.0.1", 3000), "127.0.0.1:3000:3000"), # Success with mount_cwd - (MagicMock(returncode=0), None, True, False, "127.0.0.1:3000", "127.0.0.1:3000:3000"), + (MagicMock(returncode=0), None, True, False, ("127.0.0.1", 3000), "127.0.0.1:3000:3000"), # Success with GPU - (MagicMock(returncode=0), None, False, True, "127.0.0.1:3000", "127.0.0.1:3000:3000"), + (MagicMock(returncode=0), None, False, True, ("127.0.0.1", 3000), "127.0.0.1:3000:3000"), # Success with custom bind IP - (MagicMock(returncode=0), None, False, False, "0.0.0.0", "0.0.0.0:3000:3000"), + (MagicMock(returncode=0), None, False, False, ("0.0.0.0", 3000), "0.0.0.0:3000:3000"), # Success with custom bind IP:port ( MagicMock(returncode=0), None, False, False, - "192.168.1.100:8080", + ("192.168.1.100", 8080), "192.168.1.100:8080:3000", ), # Success with bare IPv6 @@ -152,8 +152,8 @@ def test_launch_gui_server_docker_not_available( None, False, False, - "::1", - "::1:3000:3000", + ("::1", 3000), + "[::1]:3000:3000", ), # Success with bracketed IPv6:port ( @@ -161,7 +161,7 @@ def test_launch_gui_server_docker_not_available( None, False, False, - "[::1]:8080", + ("[::1]", 8080), "[::1]:8080:3000", ), ], diff --git a/tests/test_main.py b/tests/test_main.py index fc89e1eb4..48f5cda0b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -311,12 +311,21 @@ def mock_textual_main(**kw): @pytest.mark.parametrize( "argv,expected_kwargs", [ - (["openhands", "serve"], {"mount_cwd": False, "gpu": False}), - (["openhands", "serve", "--mount-cwd"], {"mount_cwd": True, "gpu": False}), - (["openhands", "serve", "--gpu"], {"mount_cwd": False, "gpu": True}), + ( + ["openhands", "serve"], + {"mount_cwd": False, "gpu": False, "bind_address": ("127.0.0.1", 3000)}, + ), + ( + ["openhands", "serve", "--mount-cwd"], + {"mount_cwd": True, "gpu": False, "bind_address": ("127.0.0.1", 3000)}, + ), + ( + ["openhands", "serve", "--gpu"], + {"mount_cwd": False, "gpu": True, "bind_address": ("127.0.0.1", 3000)}, + ), ( ["openhands", "serve", "--mount-cwd", "--gpu"], - {"mount_cwd": True, "gpu": True}, + {"mount_cwd": True, "gpu": True, "bind_address": ("127.0.0.1", 3000)}, ), ], ) From 1a2a3e15a436088ffba4cecf42e186e767714d8f Mon Sep 17 00:00:00 2001 From: David Tomaschik Date: Wed, 6 May 2026 18:22:47 -0700 Subject: [PATCH 3/3] Fix lint --- openhands_cli/argparsers/serve_parser.py | 3 ++- openhands_cli/gui_launcher.py | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openhands_cli/argparsers/serve_parser.py b/openhands_cli/argparsers/serve_parser.py index 843613376..fa8c388f0 100644 --- a/openhands_cli/argparsers/serve_parser.py +++ b/openhands_cli/argparsers/serve_parser.py @@ -86,7 +86,8 @@ def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.Argumen ) serve_parser.add_argument( "--bind", - help="Bind the GUI server to a specific IP or IP:port (e.g., 127.0.0.1 or 127.0.0.1:3000)", + help="Bind the GUI server to a specific IP or IP:port (e.g., 127.0.0.1 " + "or 127.0.0.1:3000)", type=parse_bind_address, default="127.0.0.1:3000", ) diff --git a/openhands_cli/gui_launcher.py b/openhands_cli/gui_launcher.py index 3eb5f4d3b..982d67384 100644 --- a/openhands_cli/gui_launcher.py +++ b/openhands_cli/gui_launcher.py @@ -5,7 +5,6 @@ import subprocess import sys from pathlib import Path -from urllib.parse import urlsplit from rich.console import Console from rich.markup import escape @@ -125,8 +124,8 @@ def launch_gui_server( host_ip, host_port_int = bind_address host_port = str(host_port_int) - # If it's an IPv6 address, we need to wrap it in brackets for the Docker port mapping - # and the URL display, but only if it's not already bracketed. + # If it's an IPv6 address, we need to wrap it in brackets for the Docker port + # mapping and the URL display, but only if it's not already bracketed. if ":" in host_ip and not host_ip.startswith("["): host_ip = f"[{host_ip}]"