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
|
socket_path: str
|
||||||
remote: str | None = None
|
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:
|
def _active_endpoints(reg: dict) -> dict:
|
||||||
"""Return only entries whose endpoint appears reachable."""
|
"""Return only entries whose endpoint appears reachable."""
|
||||||
if is_windows():
|
if is_windows():
|
||||||
return dict(reg)
|
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:
|
def display_browser_name(profile_name: str, sock_path: str) -> str:
|
||||||
if profile_name != "default":
|
if profile_name != "default":
|
||||||
@@ -198,6 +209,23 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
|||||||
endpoint_matches.append(target)
|
endpoint_matches.append(target)
|
||||||
if len(endpoint_matches) == 1:
|
if len(endpoint_matches) == 1:
|
||||||
return endpoint_matches[0]
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"description": "Control your browser from the terminal or Python SDK",
|
"description": "Control your browser from the terminal or Python SDK",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.10.1"
|
version = "0.10.2"
|
||||||
description = "Control your real running browser from the terminal or Python SDK"
|
description = "Control your real running browser from the terminal or Python SDK"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+49
-10
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -18,6 +19,12 @@ from browser_cli.client import (
|
|||||||
)
|
)
|
||||||
from browser_cli.platform import endpoint_for_alias
|
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):
|
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
|
||||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
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)
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||||
|
|
||||||
socket_path = tmp_path / "browser.sock"
|
socket_path = tmp_path / "browser.sock"
|
||||||
socket_path.write_text("")
|
listener = _listening_unix_socket(socket_path)
|
||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}))
|
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}))
|
||||||
|
|
||||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||||
|
|
||||||
|
try:
|
||||||
assert _resolve_socket() == str(socket_path)
|
assert _resolve_socket() == str(socket_path)
|
||||||
|
finally:
|
||||||
|
listener.close()
|
||||||
|
|
||||||
def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_path):
|
def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_path):
|
||||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||||
|
|
||||||
first_socket = tmp_path / "one.sock"
|
first_socket = tmp_path / "one.sock"
|
||||||
second_socket = tmp_path / "two.sock"
|
second_socket = tmp_path / "two.sock"
|
||||||
first_socket.write_text("")
|
first_listener = _listening_unix_socket(first_socket)
|
||||||
second_socket.write_text("")
|
second_listener = _listening_unix_socket(second_socket)
|
||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
registry_path.write_text(json.dumps({"uuid-1": str(first_socket), "uuid-2": str(second_socket)}))
|
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)
|
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||||
|
|
||||||
|
try:
|
||||||
with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"):
|
with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"):
|
||||||
_resolve_socket()
|
_resolve_socket()
|
||||||
|
finally:
|
||||||
|
first_listener.close()
|
||||||
|
second_listener.close()
|
||||||
|
|
||||||
def test_display_browser_name_uses_uuid_stem_for_default():
|
def test_display_browser_name_uses_uuid_stem_for_default():
|
||||||
assert display_browser_name("default", "/tmp/.browser_cli/550e8400-e29b-41d4-a716-446655440000.sock") == (
|
assert display_browser_name("default", "/tmp/.browser_cli/550e8400-e29b-41d4-a716-446655440000.sock") == (
|
||||||
"550e8400-e29b-41d4-a716-446655440000"
|
"550e8400-e29b-41d4-a716-446655440000"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
|
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
|
||||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
||||||
|
|
||||||
assert _resolve_socket("work") == endpoint_for_alias("work")
|
assert _resolve_socket("work") == endpoint_for_alias("work")
|
||||||
|
|
||||||
|
|
||||||
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
||||||
active_socket = tmp_path / "work.sock"
|
active_socket = tmp_path / "work.sock"
|
||||||
active_socket.write_text("")
|
listener = _listening_unix_socket(active_socket)
|
||||||
stale_socket = tmp_path / "stale.sock"
|
stale_socket = tmp_path / "stale.sock"
|
||||||
|
stale_listener = _listening_unix_socket(stale_socket)
|
||||||
|
stale_listener.close()
|
||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}))
|
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}))
|
||||||
|
|
||||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||||
|
|
||||||
|
try:
|
||||||
targets = active_browser_targets(include_remotes=False)
|
targets = active_browser_targets(include_remotes=False)
|
||||||
|
finally:
|
||||||
|
listener.close()
|
||||||
|
|
||||||
assert len(targets) == 1
|
assert len(targets) == 1
|
||||||
assert targets[0].profile == "work"
|
assert targets[0].profile == "work"
|
||||||
assert targets[0].display_name == "work"
|
assert targets[0].display_name == "work"
|
||||||
|
|
||||||
|
|
||||||
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
|
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
|
||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
|
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",
|
"browser_cli.client.remote_target_for_alias",
|
||||||
lambda alias: pytest.fail("active local profile must not trigger remote alias discovery"),
|
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")
|
payload = json.dumps({"success": True, "data": "local-ok"}).encode("utf-8")
|
||||||
framed = len(payload).to_bytes(4, "little") + payload
|
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
|
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(
|
monkeypatch.setattr(
|
||||||
"browser_cli.client._remote_browser_targets",
|
"browser_cli.client._remote_browser_targets",
|
||||||
lambda: [
|
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):
|
def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.10.1"
|
version = "0.10.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user