diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 17b524a..223d3d5 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -187,8 +187,8 @@ def _print_version(ctx, param, value): help="Browser profile alias to target (required when multiple browsers are active).", ) @click.option( - "--remote", default=None, metavar="HOST:PORT", - help="Connect to a remote browser exposed via 'browser-cli serve'.", + "--remote", default=None, metavar="HOST[:PORT]", + help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.", ) @click.option( "--key", default=None, metavar="PATH", diff --git a/browser_cli/client.py b/browser_cli/client.py index 3bffe7b..eed27f8 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -10,6 +10,7 @@ Profile selection order: """ import json import os +import re import socket import struct import uuid @@ -32,6 +33,39 @@ REGISTRY_PATH = registry_path() REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json" _DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem" +_DEFAULT_REMOTE_PORT = 443 + + +def _looks_like_domain(host: str) -> bool: + """True if host looks like a domain name rather than an IP address or localhost.""" + if host in {"localhost", "127.0.0.1", "::1"}: + return False + if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host): + return False + return '.' in host and any(c.isalpha() for c in host) + + +def _normalize_endpoint(endpoint: str) -> str: + """Strip :443 from domain-like endpoints so they are stored without the default port.""" + if not endpoint: + return endpoint + host, sep, port = endpoint.rpartition(":") + if sep and port == "443" and _looks_like_domain(host): + return host + return endpoint + + +def _resolve_connect_endpoint(endpoint: str) -> str: + """Return host:port for TCP connection; domain without port defaults to :443.""" + _, sep, _ = endpoint.rpartition(":") + if not sep: + if _looks_like_domain(endpoint): + return f"{endpoint}:{_DEFAULT_REMOTE_PORT}" + raise BrowserNotConnected( + f"Invalid remote endpoint '{endpoint}': expected host:port" + ) + return endpoint + class BrowserNotConnected(Exception): """Raised when the native host socket is not available.""" @@ -67,7 +101,8 @@ def _load_remotes() -> dict[str, dict[str, str]]: return {} if not isinstance(data, dict): return {} - return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)} + # normalize keys so old entries stored as "domain:443" match current lookups + return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)} @@ -108,8 +143,11 @@ def key_for_remote(endpoint: str | None) -> str | None: def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str: host, sep, port = endpoint.rpartition(":") - remote_name = host if sep and port == "8765" else endpoint - return f"{remote_name}:{display_name or profile_name}" + if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))): + display_endpoint = host + else: + display_endpoint = endpoint # normalized domain (no port) or non-default port + return f"{display_endpoint}:{display_name or profile_name}" def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]: @@ -239,9 +277,8 @@ def _load_private_key(key_path: "Path | str | None" = None): def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None: - host, _, port_str = endpoint.rpartition(":") - if not host or not port_str: - raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port") + connect_ep = _resolve_connect_endpoint(endpoint) + host, _, port_str = connect_ep.rpartition(":") port = int(port_str) raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) raw_sock.settimeout(30) @@ -313,6 +350,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N """Send a command to the browser and return the response data.""" requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE") remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE") + if remote_endpoint: + remote_endpoint = _normalize_endpoint(remote_endpoint) remote_alias_target = None if not remote_endpoint and requested_profile: remote_alias_target = remote_target_for_alias(requested_profile) diff --git a/extension/manifest.json b/extension/manifest.json index 4b30aa8..1e5f276 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.9.2", + "version": "0.9.4", "description": "Control your browser from the terminal via browser-cli", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index 1fc348c..5f4a369 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.9.3" +version = "0.9.4" description = "Control your real running browser from the terminal via a browser extension" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_client.py b/tests/test_client.py index 1e13873..23f9041 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,6 +6,9 @@ import pytest from browser_cli.client import ( BrowserNotConnected, BrowserTarget, + _looks_like_domain, + _normalize_endpoint, + _resolve_connect_endpoint, _resolve_socket, active_browser_targets, display_browser_name, @@ -238,6 +241,120 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path): assert targets[0].remote == endpoint +def test_looks_like_domain(): + assert _looks_like_domain("browsercli.yiprawr.dev") is True + assert _looks_like_domain("browser-host.example") is True + assert _looks_like_domain("sub.domain.org") is True + assert _looks_like_domain("localhost") is False + assert _looks_like_domain("127.0.0.1") is False + assert _looks_like_domain("192.168.1.100") is False + assert _looks_like_domain("host") is False # no dot + + +def test_normalize_endpoint_strips_443_for_domains(): + assert _normalize_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev" + assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev" + assert _normalize_endpoint("192.168.1.1:443") == "192.168.1.1:443" # IP: keep port + assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port + assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged + assert _normalize_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765" + + +def test_resolve_connect_endpoint_adds_443_for_domain(): + assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443" + assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443" + assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765" + assert _resolve_connect_endpoint("host:8765") == "host:8765" + + +def test_resolve_connect_endpoint_raises_for_bare_non_domain(): + with pytest.raises(BrowserNotConnected, match="expected host:port"): + _resolve_connect_endpoint("localhost") + + +def test_send_command_normalizes_domain_port_443(monkeypatch): + """--remote domain:443 is normalized; _send_remote gets the portless domain.""" + monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) + monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) + sent_to = {} + + monkeypatch.setattr( + "browser_cli.client.remote_browser_targets", + lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)], + ) + + def fake_send_remote(endpoint, msg, private_key=None): + sent_to["endpoint"] = endpoint + return json.dumps({"success": True, "data": "ok"}).encode() + + monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote) + + result = send_command("tabs.list", remote="browsercli.yiprawr.dev:443") + assert result == "ok" + assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # stored/routed without port + + +def test_send_command_domain_without_port_defaults_to_443(monkeypatch): + """--remote domain (no port) is treated as :443.""" + monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) + monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) + sent_to = {} + + monkeypatch.setattr( + "browser_cli.client.remote_browser_targets", + lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)], + ) + + def fake_send_remote(endpoint, msg, private_key=None): + sent_to["endpoint"] = endpoint + return json.dumps({"success": True, "data": "ok"}).encode() + + monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote) + + result = send_command("tabs.list", remote="browsercli.yiprawr.dev") + assert result == "ok" + assert sent_to["endpoint"] == "browsercli.yiprawr.dev" + + +def test_domain_display_name_omits_port(monkeypatch, tmp_path): + """Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'.""" + remotes_path = tmp_path / "remotes.json" + endpoint = "browsercli.yiprawr.dev" + remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8") + monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json") + monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path) + + def fake_send_command(command, args=None, profile=None, remote=None, key=None): + return [{"profile": "automatisation", "displayName": "automatisation"}] + + monkeypatch.setattr("browser_cli.client.send_command", fake_send_command) + + targets = active_browser_targets() + + assert len(targets) == 1 + assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation" + assert targets[0].remote == endpoint + + +def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path): + """Old remotes.json with :443 still displays cleanly without the port.""" + remotes_path = tmp_path / "remotes.json" + endpoint = "browsercli.yiprawr.dev:443" # old format + remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8") + monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json") + monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path) + + def fake_send_command(command, args=None, profile=None, remote=None, key=None): + return [{"profile": "automatisation", "displayName": "automatisation"}] + + monkeypatch.setattr("browser_cli.client.send_command", fake_send_command) + + targets = active_browser_targets() + + assert len(targets) == 1 + assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation" + + def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path): """--key agent is saved on first use; omitting --key on subsequent calls reuses it.""" import json as _json diff --git a/uv.lock b/uv.lock index ca8c328..f4c053f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "browser-cli" -version = "0.9.3" +version = "0.9.4" source = { editable = "." } dependencies = [ { name = "click" },