fix(client): validate active browser endpoints
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 41s
Testing / test (push) Successful in 50s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 41s
Testing / test (push) Successful in 50s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
- Check Unix socket reachability with a real connection attempt instead of treating any existing path as active. - Report ambiguous host-only remote aliases with actionable --remote/--browser examples. - Update client tests to use listening Unix sockets and cover ambiguous remote alias errors. - Bump package and extension versions to 0.10.2.
This commit is contained in:
+30
-2
@@ -79,13 +79,24 @@ class BrowserTarget:
|
||||
socket_path: str
|
||||
remote: str | None = None
|
||||
|
||||
def _is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||
"""Return True when a Unix socket path exists and accepts connections."""
|
||||
path = Path(endpoint)
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.2)
|
||||
sock.connect(endpoint)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _active_endpoints(reg: dict) -> dict:
|
||||
"""Return only entries whose endpoint appears reachable."""
|
||||
if is_windows():
|
||||
return dict(reg)
|
||||
return {k: v for k, v in reg.items() if Path(v).exists()}
|
||||
|
||||
return {k: v for k, v in reg.items() if _is_reachable_unix_endpoint(v)}
|
||||
|
||||
def display_browser_name(profile_name: str, sock_path: str) -> str:
|
||||
if profile_name != "default":
|
||||
@@ -198,6 +209,23 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
endpoint_matches.append(target)
|
||||
if len(endpoint_matches) == 1:
|
||||
return endpoint_matches[0]
|
||||
if len(endpoint_matches) > 1:
|
||||
aliases = [target.profile for target in endpoint_matches]
|
||||
endpoint = endpoint_matches[0].remote or alias
|
||||
examples = "\n".join(
|
||||
f" browser-cli --remote {endpoint} --browser {a} ..."
|
||||
for a in aliases
|
||||
)
|
||||
display_aliases = [target.display_name for target in endpoint_matches]
|
||||
shorthand_examples = "\n".join(
|
||||
f" browser-cli --browser {a} ..."
|
||||
for a in display_aliases
|
||||
)
|
||||
raise BrowserNotConnected(
|
||||
f"Multiple remote browser instances are active on {alias}: {', '.join(aliases)}\n"
|
||||
f"Use --browser <alias> with --remote to select one:\n{examples}\n"
|
||||
f"Or use the full remote browser alias:\n{shorthand_examples}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "browser-cli"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
+49
-10
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -18,6 +19,12 @@ from browser_cli.client import (
|
||||
)
|
||||
from browser_cli.platform import endpoint_for_alias
|
||||
|
||||
def _listening_unix_socket(path: Path) -> socket.socket:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(str(path))
|
||||
sock.listen(1)
|
||||
return sock
|
||||
|
||||
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
||||
@@ -29,59 +36,67 @@ def test_resolve_socket_uses_only_active_registry_entry(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
|
||||
socket_path = tmp_path / "browser.sock"
|
||||
socket_path.write_text("")
|
||||
listener = _listening_unix_socket(socket_path)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}))
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||
|
||||
try:
|
||||
assert _resolve_socket() == str(socket_path)
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
|
||||
first_socket = tmp_path / "one.sock"
|
||||
second_socket = tmp_path / "two.sock"
|
||||
first_socket.write_text("")
|
||||
second_socket.write_text("")
|
||||
first_listener = _listening_unix_socket(first_socket)
|
||||
second_listener = _listening_unix_socket(second_socket)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"uuid-1": str(first_socket), "uuid-2": str(second_socket)}))
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||
|
||||
try:
|
||||
with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"):
|
||||
_resolve_socket()
|
||||
|
||||
finally:
|
||||
first_listener.close()
|
||||
second_listener.close()
|
||||
|
||||
def test_display_browser_name_uses_uuid_stem_for_default():
|
||||
assert display_browser_name("default", "/tmp/.browser_cli/550e8400-e29b-41d4-a716-446655440000.sock") == (
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
||||
|
||||
assert _resolve_socket("work") == endpoint_for_alias("work")
|
||||
|
||||
|
||||
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
||||
active_socket = tmp_path / "work.sock"
|
||||
active_socket.write_text("")
|
||||
listener = _listening_unix_socket(active_socket)
|
||||
stale_socket = tmp_path / "stale.sock"
|
||||
stale_listener = _listening_unix_socket(stale_socket)
|
||||
stale_listener.close()
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}))
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||
|
||||
try:
|
||||
targets = active_browser_targets(include_remotes=False)
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].profile == "work"
|
||||
assert targets[0].display_name == "work"
|
||||
|
||||
|
||||
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
|
||||
@@ -129,6 +144,7 @@ def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monke
|
||||
"browser_cli.client.remote_target_for_alias",
|
||||
lambda alias: pytest.fail("active local profile must not trigger remote alias discovery"),
|
||||
)
|
||||
monkeypatch.setattr("browser_cli.client._is_active_local_profile", lambda profile: True)
|
||||
|
||||
payload = json.dumps({"success": True, "data": "local-ok"}).encode("utf-8")
|
||||
framed = len(payload).to_bytes(4, "little") + payload
|
||||
@@ -237,7 +253,7 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch):
|
||||
def test_remote_target_for_alias_reports_ambiguous_host_for_multiple_targets(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [
|
||||
@@ -246,7 +262,30 @@ def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets
|
||||
],
|
||||
)
|
||||
|
||||
assert remote_target_for_alias("host") is None
|
||||
with pytest.raises(BrowserNotConnected) as exc:
|
||||
remote_target_for_alias("host")
|
||||
|
||||
msg = str(exc.value)
|
||||
assert "Multiple remote browser instances are active on host: main, work" in msg
|
||||
assert "browser-cli --remote host:8765 --browser main" in msg
|
||||
assert "browser-cli --browser host:work" in msg
|
||||
|
||||
|
||||
def test_send_command_reports_ambiguous_remote_browser_alias(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
||||
monkeypatch.setattr("browser_cli.client.is_windows", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active on host: main, work"):
|
||||
send_command("tabs.list")
|
||||
|
||||
|
||||
def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user