feat: default port 443 for domain remotes, strip from display (v0.9.4)
Testing / test (push) Failing after 13m12s
Testing / test (push) Failing after 13m12s
- Domain-like --remote endpoints default to port 443; :443 is optional - _normalize_endpoint strips :443 before storage in remotes.json - _load_remotes normalises keys on load (backward compat migration) - _remote_display_name omits :443 for domain endpoints - _resolve_connect_endpoint adds :443 back for TCP connection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -187,8 +187,8 @@ def _print_version(ctx, param, value):
|
|||||||
help="Browser profile alias to target (required when multiple browsers are active).",
|
help="Browser profile alias to target (required when multiple browsers are active).",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--remote", default=None, metavar="HOST:PORT",
|
"--remote", default=None, metavar="HOST[:PORT]",
|
||||||
help="Connect to a remote browser exposed via 'browser-cli serve'.",
|
help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--key", default=None, metavar="PATH",
|
"--key", default=None, metavar="PATH",
|
||||||
|
|||||||
+45
-6
@@ -10,6 +10,7 @@ Profile selection order:
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import uuid
|
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"
|
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_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):
|
class BrowserNotConnected(Exception):
|
||||||
"""Raised when the native host socket is not available."""
|
"""Raised when the native host socket is not available."""
|
||||||
@@ -67,7 +101,8 @@ def _load_remotes() -> dict[str, dict[str, str]]:
|
|||||||
return {}
|
return {}
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return {}
|
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:
|
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
|
||||||
host, sep, port = endpoint.rpartition(":")
|
host, sep, port = endpoint.rpartition(":")
|
||||||
remote_name = host if sep and port == "8765" else endpoint
|
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
|
||||||
return f"{remote_name}:{display_name or profile_name}"
|
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]:
|
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:
|
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||||
host, _, port_str = endpoint.rpartition(":")
|
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||||
if not host or not port_str:
|
host, _, port_str = connect_ep.rpartition(":")
|
||||||
raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port")
|
|
||||||
port = int(port_str)
|
port = int(port_str)
|
||||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
raw_sock.settimeout(30)
|
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."""
|
"""Send a command to the browser and return the response data."""
|
||||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||||
|
if remote_endpoint:
|
||||||
|
remote_endpoint = _normalize_endpoint(remote_endpoint)
|
||||||
remote_alias_target = None
|
remote_alias_target = None
|
||||||
if not remote_endpoint and requested_profile:
|
if not remote_endpoint and requested_profile:
|
||||||
remote_alias_target = remote_target_for_alias(requested_profile)
|
remote_alias_target = remote_target_for_alias(requested_profile)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.9.2",
|
"version": "0.9.4",
|
||||||
"description": "Control your browser from the terminal via browser-cli",
|
"description": "Control your browser from the terminal via browser-cli",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
description = "Control your real running browser from the terminal via a browser extension"
|
description = "Control your real running browser from the terminal via a browser extension"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import pytest
|
|||||||
from browser_cli.client import (
|
from browser_cli.client import (
|
||||||
BrowserNotConnected,
|
BrowserNotConnected,
|
||||||
BrowserTarget,
|
BrowserTarget,
|
||||||
|
_looks_like_domain,
|
||||||
|
_normalize_endpoint,
|
||||||
|
_resolve_connect_endpoint,
|
||||||
_resolve_socket,
|
_resolve_socket,
|
||||||
active_browser_targets,
|
active_browser_targets,
|
||||||
display_browser_name,
|
display_browser_name,
|
||||||
@@ -238,6 +241,120 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
|||||||
assert targets[0].remote == endpoint
|
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):
|
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."""
|
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user