feat: harden remote serve and reuse connections
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels. - Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips. - Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely. - Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows. - Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0. - Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
This commit is contained in:
@@ -7,6 +7,7 @@ from browser_cli.client.core import (
|
||||
_send_remote,
|
||||
_send_remote_async,
|
||||
active_browser_targets,
|
||||
collect_browser_clients,
|
||||
remote_browser_targets,
|
||||
remote_browser_targets_async,
|
||||
remote_target_for_alias,
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
"_send_remote",
|
||||
"_send_remote_async",
|
||||
"active_browser_targets",
|
||||
"collect_browser_clients",
|
||||
"display_browser_name",
|
||||
"remote_browser_targets",
|
||||
"remote_browser_targets_async",
|
||||
|
||||
+203
-8
@@ -15,11 +15,39 @@ from browser_cli import local_transport
|
||||
from browser_cli.client import auth, messages, targets as target_discovery
|
||||
from browser_cli.client.targets import BrowserTarget
|
||||
from browser_cli.remote import registry as remote_registry
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.endpoints import _remote_display_name
|
||||
from browser_cli.endpoints import _remote_display_name, display_browser_name
|
||||
from browser_cli.registry import load_registry
|
||||
from browser_cli.remote.transport import _send_remote, _send_remote_async
|
||||
|
||||
def _run_concurrent(factories: list) -> list:
|
||||
"""Run async thunks concurrently, returning results in order.
|
||||
|
||||
Each item in *factories* is a zero-arg callable returning a coroutine. The
|
||||
return list mirrors the input order; a thunk that raises yields its exception
|
||||
object in that slot (callers filter as they would in a sequential loop). Falls
|
||||
back to sequential execution if an event loop is already running on this
|
||||
thread (e.g. inside the async serve handler), where ``asyncio.run`` is illegal.
|
||||
"""
|
||||
if not factories:
|
||||
return []
|
||||
|
||||
async def _gather():
|
||||
return await asyncio.gather(*(factory() for factory in factories), return_exceptions=True)
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_gather())
|
||||
|
||||
# An event loop is already running on this thread (e.g. the async serve
|
||||
# handler), where asyncio.run is illegal. Run the gather on a worker thread
|
||||
# that has no loop of its own, preserving concurrency and result order.
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
return executor.submit(lambda: asyncio.run(_gather())).result()
|
||||
|
||||
def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for item in items or []:
|
||||
@@ -27,6 +55,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
display = str(item.get("displayName") or profile)
|
||||
display_name = _remote_display_name(endpoint, profile, display)
|
||||
browser_name = item.get("browserName") or item.get("name")
|
||||
version = item.get("version")
|
||||
extension_version = item.get("extensionVersion")
|
||||
targets.append(
|
||||
BrowserTarget(
|
||||
profile=profile,
|
||||
@@ -35,6 +65,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
remote=endpoint,
|
||||
browser_name=str(browser_name) if browser_name else None,
|
||||
display_group=display_name.rsplit(":", 1)[0],
|
||||
version=str(version) if version else None,
|
||||
extension_version=str(extension_version) if extension_version else None,
|
||||
)
|
||||
)
|
||||
return targets
|
||||
@@ -48,12 +80,20 @@ def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool
|
||||
)
|
||||
|
||||
def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
endpoints = list(remote_registry.load_remotes())
|
||||
if not endpoints:
|
||||
return []
|
||||
results = _run_concurrent([
|
||||
(lambda ep=ep: asyncio.to_thread(remote_browser_targets, ep, key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
for ep in endpoints
|
||||
])
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint in remote_registry.load_remotes():
|
||||
try:
|
||||
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
for result in results:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
targets.extend(result)
|
||||
return targets
|
||||
|
||||
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
|
||||
@@ -111,6 +151,160 @@ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_p
|
||||
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
return targets
|
||||
|
||||
def _cached_client_row(target: BrowserTarget) -> dict | None:
|
||||
"""Build a clients row from a target's discovery data, skipping a roundtrip.
|
||||
|
||||
Returns None when the remote didn't advertise its version (older serve), so
|
||||
callers fall back to an explicit ``clients.list`` query.
|
||||
"""
|
||||
if target.version is None and target.extension_version is None:
|
||||
return None
|
||||
return {
|
||||
"profile": target.display_name,
|
||||
"profileGroup": target.display_group,
|
||||
"name": target.browser_name or "",
|
||||
"version": target.version or "",
|
||||
"extensionVersion": target.extension_version or "",
|
||||
}
|
||||
|
||||
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
|
||||
rows = []
|
||||
for item in result or []:
|
||||
row = dict(item)
|
||||
row["profile"] = label
|
||||
if profile_group:
|
||||
row["profileGroup"] = profile_group
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
async def _client_rows_async(
|
||||
label: str,
|
||||
*,
|
||||
profile: str | None = None,
|
||||
remote: str | None = None,
|
||||
key=None,
|
||||
suppress_pq_warning: bool = False,
|
||||
profile_group: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return display-ready clients.list rows for one browser target."""
|
||||
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
||||
result = await asyncio.to_thread(
|
||||
send_command,
|
||||
"clients.list",
|
||||
profile=profile,
|
||||
remote=remote,
|
||||
key=key,
|
||||
**kwargs,
|
||||
)
|
||||
return _rows_from_result(result, label, profile_group)
|
||||
|
||||
def collect_browser_clients(
|
||||
*,
|
||||
browser_alias: str | None = None,
|
||||
remote: str | None = None,
|
||||
key=None,
|
||||
registry_path=None,
|
||||
) -> list[dict]:
|
||||
"""Return display-ready browser client rows for CLI/SDK consumers.
|
||||
|
||||
Rows preserve the CLI-facing shape: ``profile``, optional ``profileGroup``,
|
||||
``name``, ``version``, and ``extensionVersion``.
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
|
||||
if not remote and browser_alias:
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if not resolved:
|
||||
return rows
|
||||
targets = remote_browser_targets(resolved.remote)
|
||||
uncached = []
|
||||
for target in targets:
|
||||
cached = _cached_client_row(target)
|
||||
if cached is not None:
|
||||
rows.append(cached)
|
||||
else:
|
||||
uncached.append(target)
|
||||
results = _run_concurrent([
|
||||
(lambda t=t: _client_rows_async(
|
||||
t.display_name,
|
||||
profile=t.profile,
|
||||
remote=resolved.remote,
|
||||
key=key,
|
||||
profile_group=t.display_group,
|
||||
))
|
||||
for t in uncached
|
||||
])
|
||||
for result in results:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
rows.extend(result)
|
||||
return rows
|
||||
|
||||
if remote:
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for item in result or []:
|
||||
row = dict(item)
|
||||
row["profile"] = row.get("profile") or browser_alias or "remote"
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
path = registry_path or target_discovery.REGISTRY_PATH
|
||||
profiles: dict[str, str] = load_registry(path) if path.exists() else {}
|
||||
local_items = list(profiles.items())
|
||||
|
||||
remote_targets = []
|
||||
cached_remote_rows = [] # deferred so local profiles still render first
|
||||
for target in active_browser_targets(suppress_pq_warning=True):
|
||||
if target.remote is None:
|
||||
continue
|
||||
cached = _cached_client_row(target)
|
||||
if cached is not None:
|
||||
cached_remote_rows.append(cached) # discovery already carried version/extVersion — no extra roundtrip
|
||||
else:
|
||||
remote_targets.append(target)
|
||||
|
||||
factories = [
|
||||
(lambda name=name, sock=sock: _client_rows_async(
|
||||
display_browser_name(name, sock), profile=name, profile_group="local",
|
||||
))
|
||||
for name, sock in local_items
|
||||
] + [
|
||||
(lambda t=t: _client_rows_async(
|
||||
t.display_name,
|
||||
profile=t.profile,
|
||||
remote=t.remote,
|
||||
suppress_pq_warning=True,
|
||||
profile_group=t.display_group,
|
||||
))
|
||||
for t in remote_targets
|
||||
]
|
||||
results = _run_concurrent(factories)
|
||||
|
||||
for (name, sock), result in zip(local_items, results[:len(local_items)]):
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
rows.append({
|
||||
"profile": display_browser_name(name, sock),
|
||||
"profileGroup": "local",
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
elif isinstance(result, BaseException):
|
||||
raise result
|
||||
else:
|
||||
rows.extend(result)
|
||||
|
||||
for result in results[len(local_items):]:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
rows.extend(result)
|
||||
rows.extend(cached_remote_rows)
|
||||
return rows
|
||||
|
||||
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
@@ -159,11 +353,12 @@ def send_command(
|
||||
|
||||
return messages.decode_response(response)
|
||||
|
||||
async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
async def remote_browser_targets_async(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
"""Async variant of :func:`remote_browser_targets`."""
|
||||
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
||||
return _remote_target_items(
|
||||
endpoint,
|
||||
await send_command_async("browser-cli.targets", remote=endpoint, key=key),
|
||||
await send_command_async("browser-cli.targets", remote=endpoint, key=key, **kwargs),
|
||||
)
|
||||
|
||||
async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
|
||||
|
||||
@@ -19,6 +19,11 @@ class BrowserTarget:
|
||||
remote: str | None = None
|
||||
browser_name: str | None = None
|
||||
display_group: str | None = None
|
||||
# Populated from a remote ``browser-cli.targets`` response when the remote is
|
||||
# new enough to advertise them, letting ``clients`` skip a redundant
|
||||
# ``clients.list`` roundtrip. None means "unknown — fall back to a query".
|
||||
version: str | None = None
|
||||
extension_version: str | None = None
|
||||
|
||||
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||
"""Return True when a Unix socket path exists and accepts connections."""
|
||||
|
||||
Reference in New Issue
Block a user