Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions openhands_cli/argparsers/serve_parser.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
4 changes: 3 additions & 1 deletion openhands_cli/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions openhands_cli/gui_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down
59 changes: 52 additions & 7 deletions tests/test_gui_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
),
],
)
Expand Down