Compare commits

..

6 Commits

Author SHA1 Message Date
daniel156161 c1a5ef9dd7 feat: token-auth removal, security hardening, Stripe-style compat layer (v0.9.2)
Testing / test (push) Successful in 41s
Package Extension / package-extension (push) Successful in 35s
Build & Publish Package / publish (push) Successful in 46s
- Remove token auth entirely; only Ed25519 pubkey auth or --no-auth
- Add 32 MB message-size cap in serve and client (DoS protection)
- Set Unix socket to 0o600 after bind in native_host (multi-user hardening)
- Enforce browser-cli/VERSION user-agent on all TCP connections
- Add PROTOCOL_MIN_CLIENT check (>= 0.9.0) server- and client-side
- Include server_version + min_client_version in challenge frame
- Add browser_cli/version_manager.py: parse_version, get_installed_version
- Add browser_cli/compat.py: Stripe-style versioning layer with adapt_request
  / adapt_response hooks; baseline 0.9.2, no shims needed yet
- Fix BrowserCLI key handling: no Path() wrap for agent specs
- Fix _multi_browser_targets() to forward key to remote_browser_targets()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:59:46 +02:00
daniel156161 b98c4ae116 fix: validate key spec before saving/loading from remotes.json
Testing / test (push) Successful in 29s
A previous bug (fixed in fcd2e8b) caused str(AgentKey(...)) to be saved
as the key spec instead of the plain string "agent". This made
_load_private_key() return None, sending messages unsigned.

- _is_valid_key_spec() guards save_remote_key() against persisting
  serialized objects or other non-spec values
- key_for_remote() rejects already-persisted corrupt specs so fallback
  key loading still works

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:15:09 +02:00
daniel156161 fcd2e8b87b fix: skip route-resolution for server-side auth commands; pass key_spec not loaded key
Testing / test (push) Failing after 14m2s
browser-cli.auth.keys and browser-cli.auth.trust are handled by serve.py
directly and never need a _route profile, so they no longer trigger
_auto_route_remote (which would open a second connection just to discover
available browser profiles).

Also fixes _auto_route_remote receiving an already-loaded AgentKey object
instead of the key spec string — the nested send_command call couldn't
re-load it for signing, causing auth failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:03:50 +02:00
daniel156161 b87f536ecd chore: bump version to 0.9.1
Testing / test (push) Failing after 11m6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:56:39 +02:00
daniel156161 a2aa031d71 feat: auth keys shows trusted keys with names; remote auth trust/keys
- authorized_keys format extended to '<hex> [optional-name]'
- auth keys repurposed: shows server's trusted keys (Name/Public Key table)
  instead of local client keys; --remote queries the remote serve instance
- auth trust gains --name flag for labelling keys; --remote pushes the key
  to the remote server's authorized_keys
- serve.py handles browser-cli.auth.keys and browser-cli.auth.trust as
  server-side commands (authenticated, never forwarded to native host)
- serve.py reloads authorized_keys from disk on every connection so
  auth trust --remote takes effect immediately without restarting serve
- auth show unchanged: still prints your own client public key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:54:41 +02:00
daniel156161 8593916e5a fix: propagate key through remote discovery; auto-persist key per remote
- remote_browser_targets(), _auto_route_remote(), active_browser_targets()
  now accept and forward the key parameter so pubkey auth works during
  the initial browser-cli.targets discovery call
- _multi_browser_targets() in tabs/groups/windows/session commands now
  reads key from ctx.obj and passes it through
- send_command() auto-saves the key spec (e.g. "agent") to remotes.json
  on first explicit use; subsequent calls to the same remote reuse it
  without requiring --key every time
- Added save_remote_key() / key_for_remote() helpers (mirrors token helpers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:50:51 +02:00
18 changed files with 467 additions and 278 deletions
+8 -17
View File
@@ -18,7 +18,6 @@ Usage:
"""
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from browser_cli.models import Group, Tab
@@ -34,7 +33,7 @@ class BrowserCounts:
class BrowserCLI:
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None, key: str | None = None):
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
"""
Args:
browser: Profile alias to target. Required when multiple browser
@@ -43,24 +42,22 @@ class BrowserCLI:
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
Can be combined with ``browser`` to route to a specific
remote profile.
token: Auth token for the remote serve instance (legacy token auth).
key: Path to Ed25519 private key PEM for pubkey auth. When set,
overrides token auth. Defaults to ``~/.config/browser-cli/client.key.pem``
if that file exists.
key: Path to Ed25519 private key PEM for pubkey auth, or ``"agent"``
to use a key from the SSH agent (YubiKey, gpg-agent, etc.).
Defaults to ``~/.config/browser-cli/client.key.pem`` if that file exists.
"""
self._browser = browser
self._remote = remote
self._token = token
self._key = Path(key) if key else None
self._key = key if key else None
def _cmd(self, command: str, args: dict | None = None):
return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token, key=self._key)
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
def _multi_browser_targets(self):
if self._browser is not None:
return []
if self._remote:
targets = remote_browser_targets(self._remote, self._token)
targets = remote_browser_targets(self._remote, key=self._key)
else:
targets = active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -73,7 +70,7 @@ class BrowserCLI:
for target in targets:
try:
if target.remote:
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token, key=self._key)
data = send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
else:
data = send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
@@ -98,7 +95,6 @@ class BrowserCLI:
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_token: str | None = None,
) -> Tab:
tab = Tab(
id=data["id"],
@@ -113,7 +109,6 @@ class BrowserCLI:
tab._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
token=browser_token,
)
return tab
@@ -124,7 +119,6 @@ class BrowserCLI:
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_token: str | None = None,
) -> Group:
group = Group(
id=data["id"],
@@ -137,7 +131,6 @@ class BrowserCLI:
group._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
token=browser_token,
)
return group
@@ -237,7 +230,6 @@ class BrowserCLI:
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
browser_token=target.token,
)
for target, tabs in multi_results
for tab in (tabs or [])
@@ -392,7 +384,6 @@ class BrowserCLI:
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
browser_token=target.token,
)
for target, groups in multi_results
for group in (groups or [])
+20 -9
View File
@@ -182,22 +182,33 @@ def new_nonce() -> str:
return secrets.token_hex(32)
def load_authorized_keys(path: Path) -> list[str]:
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
if not path.exists():
return []
return [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.startswith("#")
]
result = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
return result
def add_authorized_key(path: Path, pub_hex: str) -> bool:
def load_authorized_keys(path: Path) -> list[str]:
return [pk for pk, _ in load_authorized_keys_with_names(path)]
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = set(load_authorized_keys(path))
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = (f"{pub_hex} {name}".rstrip()) + "\n"
with open(path, "a", encoding="utf-8") as f:
f.write(pub_hex + "\n")
f.write(line)
return True
+58 -47
View File
@@ -30,7 +30,6 @@ from browser_cli.client import (
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
save_remote_token,
remote_target_for_alias,
remote_browser_targets,
)
@@ -191,16 +190,12 @@ def _print_version(ctx, param, value):
"--remote", default=None, metavar="HOST:PORT",
help="Connect to a remote browser exposed via 'browser-cli serve'.",
)
@click.option(
"--token", default=None, metavar="TOKEN",
help="Auth token for the remote browser-cli serve instance.",
)
@click.option(
"--key", default=None, metavar="PATH",
help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
)
@click.pass_context
def main(ctx, browser, remote, token, key):
def main(ctx, browser, remote, key):
"""Control your running browser from the terminal via a Chrome extension."""
ctx.ensure_object(dict)
ctx.obj["browser"] = browser
@@ -209,13 +204,10 @@ def main(ctx, browser, remote, token, key):
os.environ["BROWSER_CLI_PROFILE"] = browser
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
ctx.obj["remote"] = remote
ctx.obj["token"] = token
ctx.obj["key"] = key
if remote:
os.environ["BROWSER_CLI_REMOTE"] = remote
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None))
if token:
save_remote_token(remote, token)
if key:
os.environ["BROWSER_CLI_KEY"] = key
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
@@ -252,9 +244,11 @@ def cmd_auth_keygen(output, force):
@auth_group.command("trust")
@click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
def cmd_auth_trust(pubkey, keys_file):
"""Add a public key to the authorized keys file on the serve host."""
@click.pass_context
def cmd_auth_trust(ctx, pubkey, name, keys_file):
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
if len(pubkey) != 64:
@@ -266,10 +260,28 @@ def cmd_auth_trust(pubkey, keys_file):
console.print("[red]Invalid public key:[/red] not valid hex")
sys.exit(1)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.trust",
args={"pubkey": pubkey, "name": name},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
added = (result or {}).get("added", False)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
else:
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
added = add_authorized_key(path, pubkey)
added = add_authorized_key(path, pubkey, name)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted: [cyan]{pubkey}[/cyan]")
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
console.print(f" File: {path}")
console.print(f"\nStart the server with:")
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
@@ -313,39 +325,40 @@ def cmd_auth_show(key_src):
@auth_group.command("keys")
def cmd_auth_keys():
"""List all Ed25519 keys available for pubkey auth (file + SSH agent)."""
from browser_cli.auth import DEFAULT_KEY_PATH, agent_list_keys, load_private_key, public_key_hex
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
def cmd_auth_keys(ctx, keys_file):
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
from rich.table import Table
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Source")
table.add_column("Comment / Path")
table.add_column("Public Key")
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
)
entries = result or []
source_label = remote
else:
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
source_label = str(path)
# File key
if DEFAULT_KEY_PATH.exists():
try:
priv = load_private_key(DEFAULT_KEY_PATH)
hex_key = public_key_hex(priv)
table.add_row("[green]file[/green]", str(DEFAULT_KEY_PATH), hex_key)
except Exception as e:
table.add_row("[red]file[/red]", str(DEFAULT_KEY_PATH), f"[red]{e}[/red]")
# Agent keys
try:
agent_keys = agent_list_keys()
for k in agent_keys:
table.add_row("[cyan]agent[/cyan]", k.comment, public_key_hex(k))
except Exception as e:
table.add_row("[dim]agent[/dim]", f"[dim]{e}[/dim]", "")
if table.row_count == 0:
console.print("[yellow]No keys found.[/yellow] Run: [dim]browser-cli auth keygen[/dim]")
if not entries:
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Public Key")
for entry in entries:
name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""))
console.print(table)
console.print("\nTo trust a key on the serve host:")
console.print(" [dim]browser-cli auth trust <public-key>[/dim]")
main.add_command(auth_group)
@@ -378,7 +391,6 @@ def clients_group(ctx):
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
token = (ctx.obj or {}).get("token") or os.environ.get("BROWSER_CLI_TOKEN")
key = (ctx.obj or {}).get("key")
if not remote and browser_alias:
@@ -386,15 +398,14 @@ def clients_group(ctx):
# then show ALL clients from that remote (not just the one resolved profile).
resolved = remote_target_for_alias(browser_alias)
if resolved:
resolved_token = token or resolved.token
try:
targets = remote_browser_targets(resolved.remote, resolved_token)
targets = remote_browser_targets(resolved.remote)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
for target in targets:
try:
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, token=resolved_token, key=key)
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, key=key)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
@@ -402,7 +413,7 @@ def clients_group(ctx):
continue
elif remote:
try:
result = send_command("clients.list", profile=browser_alias, remote=remote, token=token, key=key)
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for c in (result or []):
c["profile"] = c.get("profile") or browser_alias or "remote"
all_clients.append(c)
@@ -434,7 +445,7 @@ def clients_group(ctx):
if target.remote is None:
continue
try:
result = send_command("clients.list", profile=target.profile, remote=target.remote, token=target.token)
result = send_command("clients.list", profile=target.profile, remote=target.remote)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
+65 -27
View File
@@ -21,6 +21,12 @@ from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.registry import load_registry
try:
from importlib.metadata import version as _pkg_version
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
except Exception:
_USER_AGENT = "browser-cli/0"
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"
@@ -36,7 +42,6 @@ class BrowserTarget:
display_name: str
socket_path: str
remote: str | None = None
token: str | None = None
def _active_endpoints(reg: dict) -> dict:
@@ -64,13 +69,21 @@ def _load_remotes() -> dict[str, dict[str, str]]:
return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def save_remote_token(endpoint: str, token: str | None) -> None:
"""Persist the auth token for a remote endpoint used by this client."""
if not endpoint or not token:
def _is_valid_key_spec(s: str) -> bool:
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and "/" in s or Path(s).suffix in {".pem", ".key"})
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
if not endpoint or not key_spec:
return
if not _is_valid_key_spec(key_spec):
return # refuse to save serialized objects or other garbage
remotes = _load_remotes()
current = remotes.get(endpoint, {})
current["token"] = token
current["key"] = key_spec
remotes[endpoint] = current
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
@@ -78,12 +91,18 @@ def save_remote_token(endpoint: str, token: str | None) -> None:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
def token_for_remote(endpoint: str | None) -> str | None:
def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
cfg = _load_remotes().get(endpoint) or {}
token = cfg.get("token")
return str(token) if token else None
key = cfg.get("key")
if not key:
return None
key_str = str(key)
# reject corrupted values (e.g. str(AgentKey(...)) saved by an older bug)
if not _is_valid_key_spec(key_str):
return None
return key_str
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
@@ -92,9 +111,9 @@ def _remote_display_name(endpoint: str, profile_name: str, display_name: str) ->
return f"{remote_name}:{display_name or profile_name}"
def remote_browser_targets(endpoint: str, token: str | None = None) -> list[BrowserTarget]:
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
"""Return browser targets advertised by a single remote endpoint."""
remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token)
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
targets: list[BrowserTarget] = []
for item in remote_targets or []:
profile = str(item.get("profile") or "default")
@@ -105,18 +124,16 @@ def remote_browser_targets(endpoint: str, token: str | None = None) -> list[Brow
display_name=_remote_display_name(endpoint, profile, display),
socket_path="",
remote=endpoint,
token=token,
)
)
return targets
def _remote_browser_targets() -> list[BrowserTarget]:
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for endpoint, cfg in _load_remotes().items():
token = str(cfg.get("token") or "") or None
for endpoint in _load_remotes():
try:
targets.extend(remote_browser_targets(endpoint, token))
targets.extend(remote_browser_targets(endpoint, key=key))
except (BrowserNotConnected, RuntimeError):
continue
return targets
@@ -144,7 +161,7 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
return None
def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]:
def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
if REGISTRY_PATH.exists():
reg = load_registry(REGISTRY_PATH)
@@ -153,7 +170,7 @@ def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarge
for profile, sock_path in _active_endpoints(reg).items()
)
if include_remotes:
targets.extend(_remote_browser_targets())
targets.extend(_remote_browser_targets(key=key))
return targets
@@ -238,6 +255,19 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
except (json.JSONDecodeError, AttributeError):
nonce_hex = None
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if min_ver:
from browser_cli.version_manager import parse_version
try:
client_ver = _USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
if nonce_hex and private_key is not None:
from browser_cli.auth import sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
@@ -251,8 +281,8 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
return _recv_all(sock)
def _auto_route_remote(endpoint: str, token: str | None) -> str | None:
targets = remote_browser_targets(endpoint, token)
def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1:
return targets[0].profile
if len(targets) > 1:
@@ -265,7 +295,7 @@ def _auto_route_remote(endpoint: str, token: str | None) -> str | None:
return None
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None, key: "Path | None" = None) -> Any:
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
"""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")
@@ -276,20 +306,23 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
remote_endpoint = remote_alias_target.remote
requested_profile = remote_alias_target.profile
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or (remote_alias_target.token if remote_alias_target else None) or token_for_remote(remote_endpoint)
msg = {
"id": str(uuid.uuid4()),
"command": command,
"args": args or {},
}
if remote_endpoint:
private_key = _load_private_key(key)
# use token auth only when no Ed25519 key is available
if private_key is None and resolved_token:
msg["token"] = resolved_token
msg["user_agent"] = _USER_AGENT
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
key_spec = key if key is not None else key_for_remote(remote_endpoint)
private_key = _load_private_key(key_spec)
# persist explicit key spec so future calls don't need --key
if key is not None:
save_remote_key(remote_endpoint, str(key))
route_profile = requested_profile
if not route_profile and command != "browser-cli.targets":
route_profile = _auto_route_remote(remote_endpoint, resolved_token)
_no_route_commands = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
if not route_profile and command not in _no_route_commands:
route_profile = _auto_route_remote(remote_endpoint, key=key_spec)
if route_profile:
msg["_route"] = route_profile
else:
@@ -335,9 +368,14 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
return result.get("data")
_MAX_MSG_BYTES = 32 * 1024 * 1024
def _recv_all(sock: socket.socket) -> bytes:
raw_len = _recv_exact(sock, 4)
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > _MAX_MSG_BYTES:
raise ConnectionError(f"Response too large ({msg_len} bytes)")
return _recv_exact(sock, msg_len)
+7 -6
View File
@@ -17,10 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -31,10 +31,11 @@ def _multi_browser_targets():
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets()
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -77,7 +78,7 @@ def group_list():
if targets:
groups = []
for target in targets:
result = _handle_multi("group.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("group.list", profile=target.profile, remote=target.remote)
if result is None:
continue
groups.extend({**group, "browser": target.display_name} for group in result)
@@ -110,7 +111,7 @@ def group_count():
total = 0
rows = 0
for target in targets:
count = _handle_multi("group.count", profile=target.profile, remote=target.remote, token=target.token)
count = _handle_multi("group.count", profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
+103 -45
View File
@@ -1,7 +1,12 @@
import hmac, threading, secrets, socket, struct, click, json, sys
import re, threading, secrets, socket, struct, click, json, sys, os
from pathlib import Path
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version, get_installed_version
from browser_cli.compat import adapt_request, adapt_response
_UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
_MAX_MSG_BYTES = 32 * 1024 * 1024
from rich.console import Console
from datetime import datetime
@@ -25,17 +30,10 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None, nonce:str) -> None:
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
def _send_error(msg_id, msg:str) -> None:
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
@@ -43,6 +41,16 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
except OSError:
pass
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
if msg_len > _MAX_MSG_BYTES:
_send_error(None, f"message too large ({msg_len} bytes)")
return
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
try:
msg = json.loads(payload)
except (json.JSONDecodeError, ValueError):
@@ -53,6 +61,22 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
msg_id = msg.get("id")
command = msg.get("command", "?")
# ── user-agent + version check ────────────────────────────────────────────
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
_send_error(msg_id, "forbidden: client required")
_log(addr, command, None, "DENIED", f"bad user-agent: {ua!r}")
return
client_ver = "0"
try:
client_ver = ua.split("/", 1)[1]
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
_send_error(msg_id, f"client version {client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
_log(addr, command, None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
return
except (IndexError, ValueError):
pass
# ── auth ──────────────────────────────────────────────────────────────────
if auth_keys is not None:
pub = msg.get("pubkey") or ""
@@ -70,11 +94,6 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
elif server_token is not None:
if not hmac.compare_digest(msg.get("token") or "", server_token):
_send_error(msg_id, "unauthorized: invalid or missing token")
_log(addr, command, None, "DENIED", "bad token")
return
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
@@ -87,15 +106,45 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.keys":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
data = json.dumps({"id": msg_id, "success": True, "data": entries}).encode()
client_sock.sendall(struct.pack("<I", len(data)) + data)
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.trust":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import add_authorized_key
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
if len(pubkey) != 64:
_send_error(msg_id, "invalid pubkey: expected 64 hex characters")
_log(addr, command, None, "ERROR", "invalid pubkey")
return
added = add_authorized_key(auth_keys_path, pubkey, name)
data = json.dumps({"id": msg_id, "success": True, "data": {"added": added}}).encode()
client_sock.sendall(struct.pack("<I", len(data)) + data)
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
return
resolved_profile = msg.get("_route") or profile
strip = {"token", "_route", "pubkey", "sig"}
if strip & msg.keys():
clean_payload = json.dumps({k: v for k, v in msg.items() if k not in strip}).encode()
clean_header = struct.pack("<I", len(clean_payload))
else:
clean_payload = payload
clean_header = header
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
clean_header = struct.pack("<I", len(clean_payload))
try:
sock_path = _resolve_socket(resolved_profile)
@@ -110,6 +159,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(clean_payload)
resp = pipe.recv_bytes()
resp = adapt_response(resp, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
@@ -118,7 +168,8 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
resp_header = _recv_exact(local, 4)
resp_len = struct.unpack("<I", resp_header)[0]
resp_payload = _recv_exact(local, resp_len)
client_sock.sendall(resp_header + resp_payload)
resp_payload = adapt_response(resp_payload, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp_payload)) + resp_payload)
resp_data = json.loads(resp_payload if not is_windows() else resp)
if resp_data.get("success", True):
@@ -129,32 +180,43 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", str(e))
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None) -> None:
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
client_sock.settimeout(30)
try:
with client_sock:
# reload on every connection so auth trust --remote takes effect immediately
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
auth_keys: list[str] | None = load_authorized_keys(auth_keys_path)
else:
auth_keys = None
nonce = secrets.token_hex(32)
challenge = json.dumps({"type": "challenge", "nonce": nonce}).encode()
challenge = json.dumps({
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}).encode()
try:
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, server_token, auth_keys, nonce)
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce)
finally:
_CONN_LIMIT.release()
@click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
@click.option("--token", default=None, metavar="TOKEN", help="Auth token (auto-generated if omitted).")
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Enables pubkey auth.")
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
@click.pass_context
def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
@@ -163,17 +225,14 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
path = Path(auth_keys_file)
auth_keys = load_authorized_keys(path)
if not auth_keys:
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {path}")
server_token = None
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
elif no_auth:
auth_keys = None
server_token = None
auth_keys_path = None
else:
auth_keys = None
server_token = token or secrets.token_urlsafe(32)
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
sys.exit(1)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -185,17 +244,16 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
sys.exit(1)
server.listen(16)
current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]")
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
if auth_keys is not None:
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({len(auth_keys)} trusted key{'s' if len(auth_keys) != 1 else ''})")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
elif server_token:
console.print(f" Token: [bold yellow]{server_token}[/bold yellow]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\", token=\"{server_token}\").tabs_list()[/dim]")
else:
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
@@ -206,7 +264,7 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token, auth_keys), daemon=True).start()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally:
+6 -5
View File
@@ -16,10 +16,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -30,10 +30,11 @@ def _multi_browser_targets():
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets()
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -98,7 +99,7 @@ def session_list():
if targets:
sessions = []
for target in targets:
result = _handle_multi("session.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
if result is None:
continue
sessions.extend({**session, "browser": target.display_name} for session in result)
+7 -6
View File
@@ -19,10 +19,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -33,10 +33,11 @@ def _multi_browser_targets():
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets()
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -83,7 +84,7 @@ def tabs_list():
if targets:
tabs = []
for target in targets:
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote)
if result is None:
continue
tabs.extend({**tab, "browser": target.display_name} for tab in result)
@@ -170,7 +171,7 @@ def tabs_count(pattern):
total = 0
rows = 0
for target in targets:
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote, token=target.token)
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
+6 -5
View File
@@ -17,10 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -31,10 +31,11 @@ def _multi_browser_targets():
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets()
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -75,7 +76,7 @@ def windows_list():
if targets:
windows = []
for target in targets:
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote)
if result is None:
continue
windows.extend({**window, "browser": target.display_name} for window in result)
+49
View File
@@ -0,0 +1,49 @@
"""
Stripe-style version compatibility layer for browser-cli serve.
When a behaviour-breaking change ships in a new server version, add one entry
to _COMPAT below:
("X.Y.Z", request_fn, response_fn)
- ``request_fn(msg: dict) -> dict``
Upgrade an incoming client message from a client older than X.Y.Z to the
current format before forwarding it to the native host.
- ``response_fn(resp: bytes, command: str) -> bytes``
Downgrade a native-host response to the format a client older than X.Y.Z
expects before sending it back.
Either function may be ``None`` when only one direction needs adapting.
Entries must stay in ascending version order. ``adapt_request`` walks forward
(oldest change first); ``adapt_response`` walks backward (newest change first)
so the transformations compose correctly.
Current baseline: 0.9.1 no shims needed yet.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
]
def adapt_request(msg: dict, client_version: str) -> dict:
"""Upgrade a client message to the current server format."""
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
return msg
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
"""Downgrade a server response to the format the client expects."""
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
return resp
+2
View File
@@ -156,6 +156,7 @@ def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
path.unlink()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(sock_path)
os.chmod(sock_path, 0o600)
sock.listen(16)
while True:
@@ -319,6 +320,7 @@ def main():
path.unlink()
bound_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
bound_sock.bind(sock_path)
os.chmod(sock_path, 0o600)
bound_sock.listen(16)
else:
bound_sock = None
+17
View File
@@ -0,0 +1,17 @@
from importlib.metadata import version as _pkg_version
PROTOCOL_MIN_CLIENT = "0.9.0"
def parse_version(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
def get_installed_version() -> str:
try:
return _pkg_version("browser-cli")
except Exception:
return "0.0.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.9.0",
"version": "0.9.2",
"description": "Control your browser from the terminal via browser-cli",
"permissions": [
"tabs",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "browser-cli"
version = "0.9.0"
version = "0.9.2"
description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10"
dependencies = [
+46 -47
View File
@@ -65,10 +65,9 @@ class TestBrowserCLIInit:
assert b._browser == "chrome"
def test_remote_options_stored(self):
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
b = BrowserCLI(browser="work", remote="host:8765", key=None)
assert b._browser == "work"
assert b._remote == "host:8765"
assert b._token == "secret"
# ── Internal factories ────────────────────────────────────────────────────────
@@ -129,7 +128,7 @@ class TestNavigation:
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": False, "window": None, "group": None},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_open_background(self, b, mock_send):
@@ -143,38 +142,38 @@ class TestNavigation:
def test_reload(self, b, mock_send):
b.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
def test_hard_reload(self, b, mock_send):
b.hard_reload(tab_id=7)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, key=None)
def test_back(self, b, mock_send):
b.back(tab_id=3)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, key=None)
def test_forward(self, b, mock_send):
b.forward(tab_id=3)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
def test_focus_url(self, b, mock_send):
b.focus_url("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
def test_navigate_tab(self, b, mock_send):
b.navigate_tab(5, "https://example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, key=None)
def test_remote_forwarded(self, mock_send):
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
b = BrowserCLI(browser="work", remote="host:8765", key=None)
b.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret", key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", key=None)
# ── Search ────────────────────────────────────────────────────────────────────
@@ -207,12 +206,12 @@ class TestExtract:
result = b.extract_markdown()
assert result == "# Title"
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
def test_extract_markdown_selector(self, b, mock_send):
b.extract_markdown("article")
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
# ── Tabs ──────────────────────────────────────────────────────────────────────
@@ -247,7 +246,7 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.close",
{"tabId": 10, "inactive": False, "duplicates": False},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_tabs_move(self, b, mock_send):
@@ -255,19 +254,19 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_tabs_active(self, b, mock_send):
b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_window_active_tab(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.window_active_tab(1)
assert isinstance(tab, Tab)
assert tab.id == 10
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, key=None)
def test_window_active_tab_missing_raises(self, b, mock_send):
mock_send.return_value = None
@@ -319,17 +318,17 @@ class TestTabs:
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="default"),
call("tabs.list", {}, profile="work"),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None, key=None),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
]
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret", key=None)
b = BrowserCLI(remote="host:8765", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
):
mock_send.side_effect = [[TAB_DATA], None]
tabs = b.tabs_list()
@@ -337,8 +336,8 @@ class TestTabs:
assert [tab.browser for tab in tabs] == ["host:work"]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", token="secret", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret", key=None),
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
]
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -381,7 +380,7 @@ class TestTabs:
def test_tabs_sort(self, b, mock_send):
b.tabs_sort(by="title")
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, key=None)
def test_tabs_merge_windows(self, b, mock_send):
mock_send.return_value = {"moved": 4}
@@ -414,7 +413,7 @@ class TestGroups:
mock_send.return_value = [TAB_DATA]
tabs = b.group_tabs(42)
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_group_count(self, b, mock_send):
mock_send.return_value = 7
@@ -442,17 +441,17 @@ class TestGroups:
assert mock_send.call_args_list == [
call("group.list", {}, profile="default"),
call("group.list", {}, profile="work"),
call("group.close", {"groupId": 99}, profile="work", remote=None, token=None, key=None),
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
]
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret", key=None)
b = BrowserCLI(remote="host:8765", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
):
mock_send.side_effect = [[GROUP_DATA], None]
groups = b.group_list()
@@ -460,8 +459,8 @@ class TestGroups:
assert [group.browser for group in groups] == ["host:work"]
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", token="secret", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret", key=None),
call("group.list", {}, profile="work", remote="host:8765", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -484,7 +483,7 @@ class TestGroups:
def test_group_close(self, b, mock_send):
b.group_close(42)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
def test_group_create_dict_response(self, b, mock_send):
mock_send.return_value = GROUP_DATA
@@ -504,7 +503,7 @@ class TestGroups:
tab_id = b.group_add_tab(42, "https://example.com")
assert tab_id == 55
mock_send.assert_called_once_with(
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_group_add_tab_non_dict_response(self, b, mock_send):
@@ -514,7 +513,7 @@ class TestGroups:
def test_group_move_forward(self, b, mock_send):
b.group_move(42, forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
@@ -544,7 +543,7 @@ class TestWindows:
result = b.windows_open()
assert result == {"id": 5}
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, key=None)
def test_windows_open_with_url(self, b, mock_send):
mock_send.return_value = {"id": 9}
@@ -552,7 +551,7 @@ class TestWindows:
result = b.windows_open("https://example.com")
assert result == {"id": 9}
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
class TestSession:
@@ -562,7 +561,7 @@ class TestSession:
result = b.session_list()
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, key=None)
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
@@ -597,26 +596,26 @@ class TestTabModel:
def test_close(self, tab, mock_send):
tab.close()
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
def test_activate(self, tab, mock_send):
tab.activate()
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_reload(self, tab, mock_send):
tab.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_hard_reload(self, tab, mock_send):
tab.hard_reload()
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_move_forward(self, tab, mock_send):
tab.move(forward=True)
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_move_to_group(self, tab, mock_send):
@@ -626,12 +625,12 @@ class TestTabModel:
def test_html(self, tab, mock_send):
mock_send.return_value = "<html/>"
assert tab.html() == "<html/>"
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, key=None)
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None, key=None
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
)
def test_open_background_changes_same_tab(self, tab, mock_send):
@@ -639,7 +638,7 @@ class TestTabModel:
mock_send.assert_called_once_with(
"navigate.to",
{"tabId": 10, "url": "https://new.example.com"},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_unbound_raises(self):
@@ -657,18 +656,18 @@ class TestGroupModel:
def test_close(self, group, mock_send):
group.close()
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
def test_tabs(self, group, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = group.tabs()
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_move_forward(self, group, mock_send):
group.move(forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
def test_move_backward(self, group, mock_send):
+10 -24
View File
@@ -168,19 +168,16 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
assert "0.8.2" in result.output
def test_clients_remote_uses_remote_endpoint_without_local_registry():
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "clients.list"
assert profile is None
assert remote == "127.0.0.1:8765"
assert token == "test"
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command, patch(
"browser_cli.cli.save_remote_token"
):
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--token", "test", "clients"])
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
assert result.exit_code == 0
send_command.assert_called_once()
@@ -194,7 +191,7 @@ def test_clients_remote_respects_global_browser_route():
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
assert result.exit_code == 1
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", token=None, key=None)
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
def test_clients_browser_alias_resolves_to_remote():
@@ -207,15 +204,13 @@ def test_clients_browser_alias_resolves_to_remote():
display_name="192.168.188.104:automatisation",
socket_path="",
remote="192.168.188.104:8765",
token="tok",
)
all_remote_targets = [resolved_target]
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "clients.list"
assert profile == "automatisation"
assert remote == "192.168.188.104:8765"
assert token == "tok"
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
with patch.dict(os.environ, {}, clear=True), patch(
@@ -287,17 +282,17 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.commands.tabs.remote_browser_targets",
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765", token="secret")],
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
), patch(
"browser_cli.commands.tabs.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
) as send_command, patch("browser_cli.cli.save_remote_token"):
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "--token", "secret", "tabs", "list"])
) as send_command:
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
assert result.exit_code == 0
assert "remote-host:work" in result.output
assert "Remote" in result.output
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", token="secret")
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765")
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
@@ -634,14 +629,6 @@ def test_convert_html_to_markdown_indents_multiline_list_items():
" Local LLMs / API Modelle / Spezialmodelle"
) in markdown
def test_remote_token_is_saved_when_passed_on_cli():
endpoint = "browser-host.example:8765"
with patch("browser_cli.cli.save_remote_token") as save_remote_token:
result = CliRunner().invoke(main, ["--remote", endpoint, "--token", "secret", "completion", "bash", "--script"])
assert result.exit_code == 0
save_remote_token.assert_called_once_with(endpoint, "secret")
def test_tabs_list_multi_browser_queries_remote_target():
endpoint = "browser-host.example:8765"
@@ -650,7 +637,6 @@ def test_tabs_list_multi_browser_queries_remote_target():
"browser-host.example:work",
"",
remote=endpoint,
token="secret",
)
with patch("browser_cli.commands.tabs.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
@@ -660,5 +646,5 @@ def test_tabs_list_multi_browser_queries_remote_target():
result = CliRunner().invoke(main, ["tabs", "list"])
assert result.exit_code == 0
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint, token="secret")
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint)
assert "browser-host.example:work" in result.output
+60 -37
View File
@@ -9,10 +9,9 @@ from browser_cli.client import (
_resolve_socket,
active_browser_targets,
display_browser_name,
save_remote_token,
key_for_remote,
send_command,
remote_target_for_alias,
token_for_remote,
)
from browser_cli.platform import endpoint_for_alias
@@ -93,29 +92,15 @@ def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
def test_save_remote_token_persists_per_endpoint(monkeypatch, tmp_path):
remotes_path = tmp_path / "remotes.json"
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
endpoint = "browser-host.example:8765"
save_remote_token(endpoint, "secret-token")
assert token_for_remote(endpoint) == "secret-token"
assert json.loads(remotes_path.read_text(encoding="utf-8")) == {
endpoint: {"token": "secret-token"}
}
def test_send_command_auto_routes_single_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
sent = {}
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, token=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
@@ -124,20 +109,19 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
assert send_command("tabs.list", remote="host:8765", token="secret", key=None) == "ok"
assert send_command("tabs.list", remote="host:8765", key=None) == "ok"
assert sent["_route"] == "work"
assert sent["token"] == "secret"
assert "token" not in sent
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
sent = {}
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
)
def fake_send_remote(endpoint, msg, private_key=None):
@@ -150,13 +134,13 @@ def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
assert send_command("tabs.list") == []
assert sent["endpoint"] == "host:8765"
assert sent["_route"] == "work"
assert sent["token"] == "secret"
assert "token" not in sent
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
)
target = remote_target_for_alias("host:8765:work")
@@ -171,7 +155,7 @@ def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkey
remote_endpoint = f"{remote_host}:8765"
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
)
target = remote_target_for_alias(remote_host)
@@ -185,13 +169,12 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
remote_host = "browser-host.example"
remote_endpoint = f"{remote_host}:8765"
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
monkeypatch.setenv("BROWSER_CLI_PROFILE", remote_host)
sent = {}
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
@@ -204,15 +187,15 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
assert send_command("tabs.list") == []
assert sent["endpoint"] == remote_endpoint
assert sent["_route"] == "work"
assert sent["token"] == "secret"
assert "token" not in sent
def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch):
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [
BrowserTarget("main", "host:main", "", remote="host:8765", token="secret"),
BrowserTarget("work", "host:work", "", remote="host:8765", token="secret"),
BrowserTarget("main", "host:main", "", remote="host:8765"),
BrowserTarget("work", "host:work", "", remote="host:8765"),
],
)
@@ -223,27 +206,26 @@ def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, token=None: [
BrowserTarget("main", "host:main", "", remote=endpoint, token=token),
BrowserTarget("furry", "host:furry", "", remote=endpoint, token=token),
lambda endpoint, key=None: [
BrowserTarget("main", "host:main", "", remote=endpoint),
BrowserTarget("furry", "host:furry", "", remote=endpoint),
],
)
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
send_command("tabs.list", remote="host:8765", token="secret")
send_command("tabs.list", remote="host:8765")
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
remotes_path = tmp_path / "remotes.json"
endpoint = "browser-host.example:8765"
remotes_path.write_text(json.dumps({endpoint: {"token": "secret-token"}}), encoding="utf-8")
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, token=None, key=None):
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "browser-cli.targets"
assert remote == endpoint
assert token == "secret-token"
return [{"profile": "work", "displayName": "work"}]
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
@@ -254,4 +236,45 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
assert targets[0].profile == "work"
assert targets[0].display_name == "browser-host.example:work"
assert targets[0].remote == endpoint
assert targets[0].token == "secret-token"
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
remotes_path = tmp_path / "remotes.json"
remotes_path.write_text("{}", encoding="utf-8")
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
from pathlib import Path as _Path
used_keys = []
def fake_load_private_key(key_path=None):
used_keys.append(str(key_path) if key_path is not None else None)
return None # no actual key needed for this test
monkeypatch.setattr("browser_cli.client._load_private_key", fake_load_private_key)
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
return _json.dumps({"success": True, "data": "ok"}).encode()
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
# First call with explicit --key agent
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
assert used_keys[-1] == "agent"
# Key must be persisted now
assert key_for_remote("host:8765") == "agent"
# Second call without --key — should reuse saved "agent"
send_command("tabs.list", remote="host:8765")
assert used_keys[-1] == "agent"
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.9.0"
version = "0.9.2"
source = { editable = "." }
dependencies = [
{ name = "click" },