diff --git a/openhands_cli/argparsers/serve_parser.py b/openhands_cli/argparsers/serve_parser.py index 9ca2e8f4d..fa8c388f0 100644 --- a/openhands_cli/argparsers/serve_parser.py +++ b/openhands_cli/argparsers/serve_parser.py @@ -1,6 +1,62 @@ """Argument parser for serve subcommand.""" import argparse +import ipaddress +from urllib.parse import urlsplit + + +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) + - 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: + A tuple of (host, port) + + Raises: + argparse.ArgumentTypeError: If the value is invalid + """ + 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 or 3000 + + if not host: + raise ValueError("Could not parse host from bind address") + + # Validate IP + ipaddress.ip_address(host) + + # Validate Port + if not (1 <= port <= 65535): + raise ValueError(f"Port {port} out of range") + + return host, port + 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 +84,11 @@ 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=parse_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..982d67384 100644 --- a/openhands_cli/gui_launcher.py +++ b/openhands_cli/gui_launcher.py @@ -88,13 +88,18 @@ 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: tuple[str, int] = ("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: A tuple of (host, port) to bind the server to. """ console.print("🚀 Launching OpenHands GUI server...", style="blue", markup=False) console.print() @@ -115,9 +120,18 @@ 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. + # 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. + 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 +201,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..09c1ba7e5 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", 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:3000", + ), + # Success with bare IPv6 + ( + MagicMock(returncode=0), + None, + False, + False, + ("::1", 3000), + "[::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) 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)}, ), ], )