refactor: split compat into package, harden serve proxy (v0.9.3)
Testing / test (push) Failing after 10m21s
Testing / test (push) Failing after 10m21s
- compat.py → compat/ package: auth.py (auth-field normalizers), commands.py (command-format shims), __init__.py (re-exports) - Add _auth_0_9_3 transformer: normalizes pubkey to lowercase before auth so clients < 0.9.3 sending uppercase hex are accepted - adapt_auth() now called before auth check in serve.py; command extracted after adapt_auth so future transformers can rename commands safely - serve.py: deduplicate _recv_exact (import from client), unify resp/resp_payload across Windows/Unix branches, require lowercase hex pubkey (re.fullmatch), reorganize imports, drop unused os import - client.py: move payload/framed construction inside branches (remote path no longer serializes JSON it never uses); fix _is_valid_key_spec operator precedence; import MAX_MSG_BYTES from version_manager - auth.py: narrow except clause (ValueError instead of bare Exception) - Bump version 0.9.2 → 0.9.3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -174,7 +174,7 @@ def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str) -> bool:
|
|||||||
message = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
message = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
||||||
pub_key.verify(bytes.fromhex(sig_hex), message)
|
pub_key.verify(bytes.fromhex(sig_hex), message)
|
||||||
return True
|
return True
|
||||||
except (InvalidSignature, Exception):
|
except (InvalidSignature, ValueError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
||||||
|
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
|
||||||
from browser_cli.registry import load_registry
|
from browser_cli.registry import load_registry
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -72,7 +73,7 @@ def _load_remotes() -> dict[str, dict[str, str]]:
|
|||||||
|
|
||||||
def _is_valid_key_spec(s: str) -> bool:
|
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 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"})
|
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:
|
def save_remote_key(endpoint: str, key_spec: str) -> None:
|
||||||
@@ -327,22 +328,22 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
|||||||
msg["_route"] = route_profile
|
msg["_route"] = route_profile
|
||||||
else:
|
else:
|
||||||
private_key = None
|
private_key = None
|
||||||
payload = json.dumps(msg).encode("utf-8")
|
|
||||||
framed = struct.pack("<I", len(payload)) + payload
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if remote_endpoint:
|
if remote_endpoint:
|
||||||
response = _send_remote(remote_endpoint, msg, private_key)
|
response = _send_remote(remote_endpoint, msg, private_key)
|
||||||
elif is_windows():
|
elif is_windows():
|
||||||
|
payload = json.dumps(msg).encode("utf-8")
|
||||||
sock_path = _resolve_socket(profile)
|
sock_path = _resolve_socket(profile)
|
||||||
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
||||||
conn.send_bytes(payload)
|
conn.send_bytes(payload)
|
||||||
response = conn.recv_bytes()
|
response = conn.recv_bytes()
|
||||||
else:
|
else:
|
||||||
|
payload = json.dumps(msg).encode("utf-8")
|
||||||
sock_path = _resolve_socket(profile)
|
sock_path = _resolve_socket(profile)
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||||
sock.connect(sock_path)
|
sock.connect(sock_path)
|
||||||
sock.sendall(framed)
|
sock.sendall(struct.pack("<I", len(payload)) + payload)
|
||||||
response = _recv_all(sock)
|
response = _recv_all(sock)
|
||||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||||
if remote_endpoint:
|
if remote_endpoint:
|
||||||
@@ -368,9 +369,6 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
|||||||
return result.get("data")
|
return result.get("data")
|
||||||
|
|
||||||
|
|
||||||
_MAX_MSG_BYTES = 32 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _recv_all(sock: socket.socket) -> bytes:
|
def _recv_all(sock: socket.socket) -> bytes:
|
||||||
raw_len = _recv_exact(sock, 4)
|
raw_len = _recv_exact(sock, 4)
|
||||||
msg_len = struct.unpack("<I", raw_len)[0]
|
msg_len = struct.unpack("<I", raw_len)[0]
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import click
|
||||||
|
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
_console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _handle(command, args=None, profile=None):
|
||||||
|
try:
|
||||||
|
return send_command(command, args or {}, profile=profile)
|
||||||
|
except BrowserNotConnected as e:
|
||||||
|
_console.print(f"[red]Error:[/red] {e}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
except RuntimeError as e:
|
||||||
|
_console.print(f"[red]Browser error:[/red] {e}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_multi(command, args=None, profile=None, remote=None):
|
||||||
|
try:
|
||||||
|
if remote:
|
||||||
|
return send_command(command, args or {}, profile=profile, remote=remote)
|
||||||
|
return send_command(command, args or {}, profile=profile)
|
||||||
|
except (BrowserNotConnected, RuntimeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _multi_browser_targets():
|
||||||
|
root = click.get_current_context().find_root()
|
||||||
|
if root.obj.get("browser_explicit"):
|
||||||
|
return []
|
||||||
|
remote = root.obj.get("remote")
|
||||||
|
key = root.obj.get("key")
|
||||||
|
if remote:
|
||||||
|
targets = remote_browser_targets(remote, key=key)
|
||||||
|
else:
|
||||||
|
targets = active_browser_targets(key=key)
|
||||||
|
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||||
|
return []
|
||||||
|
return targets
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import send_command, BrowserNotConnected
|
from browser_cli.commands import _handle
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {})
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group("cookies")
|
@click.group("cookies")
|
||||||
def cookies_group():
|
def cookies_group():
|
||||||
"""Manage browser cookies."""
|
"""Manage browser cookies."""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import send_command, BrowserNotConnected
|
from browser_cli.commands import _handle
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
import json
|
import json
|
||||||
@@ -7,17 +7,6 @@ import json
|
|||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {})
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group("dom")
|
@click.group("dom")
|
||||||
def dom_group():
|
def dom_group():
|
||||||
"""Query and interact with page DOM elements."""
|
"""Query and interact with page DOM elements."""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import re
|
|||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from browser_cli.client import send_command, BrowserNotConnected
|
from browser_cli.commands import _handle
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
@@ -423,17 +423,6 @@ def _convert_html_to_markdown(html):
|
|||||||
return _clean_markdown_output(markdown)
|
return _clean_markdown_output(markdown)
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {})
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group("extract")
|
@click.group("extract")
|
||||||
def extract_group():
|
def extract_group():
|
||||||
"""Extract content from the active tab."""
|
"""Extract content from the active tab."""
|
||||||
|
|||||||
@@ -1,46 +1,11 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None, profile=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_multi(command, args=None, profile=None, remote=None):
|
|
||||||
try:
|
|
||||||
if remote:
|
|
||||||
return send_command(command, args or {}, profile=profile, remote=remote)
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except (BrowserNotConnected, RuntimeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _multi_browser_targets():
|
|
||||||
root = click.get_current_context().find_root()
|
|
||||||
if root.obj.get("browser_explicit"):
|
|
||||||
return []
|
|
||||||
remote = root.obj.get("remote")
|
|
||||||
key = root.obj.get("key")
|
|
||||||
if remote:
|
|
||||||
targets = remote_browser_targets(remote, key=key)
|
|
||||||
else:
|
|
||||||
targets = active_browser_targets(key=key)
|
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
|
||||||
return []
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
|
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
|
||||||
if not groups:
|
if not groups:
|
||||||
console.print("[yellow]No groups found[/yellow]")
|
console.print("[yellow]No groups found[/yellow]")
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import send_command, BrowserNotConnected
|
from browser_cli.commands import _handle
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args):
|
|
||||||
try:
|
|
||||||
return send_command(command, args)
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group("nav")
|
@click.group("nav")
|
||||||
def nav_group():
|
def nav_group():
|
||||||
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
|
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import send_command, BrowserNotConnected
|
from browser_cli.commands import _handle
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {})
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group("page")
|
@click.group("page")
|
||||||
def page_group():
|
def page_group():
|
||||||
"""Inspect current page metadata."""
|
"""Inspect current page metadata."""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import click
|
import click
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
from browser_cli.client import send_command, BrowserNotConnected
|
from browser_cli.commands import _handle
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -71,14 +71,7 @@ def _build_command(engine_key: str, help_text: str) -> click.Command:
|
|||||||
def _cmd(query, bg, window, group):
|
def _cmd(query, bg, window, group):
|
||||||
terms = " ".join(query)
|
terms = " ".join(query)
|
||||||
url = ENGINES[engine_key].format(query=quote_plus(terms))
|
url = ENGINES[engine_key].format(query=quote_plus(terms))
|
||||||
try:
|
_handle("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
|
||||||
send_command("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
||||||
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||||
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import re, threading, secrets, socket, struct, click, json, sys, os
|
import re, threading, secrets, socket, struct, click, json, sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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
|
from rich.console import Console
|
||||||
|
|
||||||
|
from browser_cli.client import _recv_exact, _recv_all
|
||||||
|
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
|
||||||
|
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
|
||||||
|
|
||||||
_UA_PATTERN = re.compile(r"^browser-cli/\d")
|
_UA_PATTERN = re.compile(r"^browser-cli/\d")
|
||||||
|
|
||||||
_CONN_LIMIT = threading.BoundedSemaphore(64)
|
_CONN_LIMIT = threading.BoundedSemaphore(64)
|
||||||
_MAX_MSG_BYTES = 32 * 1024 * 1024
|
|
||||||
from rich.console import Console
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
def _recv_exact(sock:socket.socket, n:int) -> bytes:
|
|
||||||
buf = b""
|
def _framed_send(sock: socket.socket, data: bytes) -> None:
|
||||||
while len(buf) < n:
|
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||||
chunk = sock.recv(n - len(buf))
|
|
||||||
if not chunk:
|
|
||||||
raise ConnectionError("Connection closed")
|
|
||||||
buf += chunk
|
|
||||||
return buf
|
|
||||||
|
|
||||||
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
|
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
|
||||||
ts = datetime.now().strftime("%H:%M:%S")
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
@@ -37,14 +32,21 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
|||||||
def _send_error(msg_id, msg:str) -> None:
|
def _send_error(msg_id, msg:str) -> None:
|
||||||
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
|
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
|
||||||
try:
|
try:
|
||||||
client_sock.sendall(struct.pack("<I", len(err)) + err)
|
_framed_send(client_sock, err)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send_ok(msg_id, payload) -> None:
|
||||||
|
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
|
||||||
|
try:
|
||||||
|
_framed_send(client_sock, out)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
header = _recv_exact(client_sock, 4)
|
header = _recv_exact(client_sock, 4)
|
||||||
msg_len = struct.unpack("<I", header)[0]
|
msg_len = struct.unpack("<I", header)[0]
|
||||||
if msg_len > _MAX_MSG_BYTES:
|
if msg_len > MAX_MSG_BYTES:
|
||||||
_send_error(None, f"message too large ({msg_len} bytes)")
|
_send_error(None, f"message too large ({msg_len} bytes)")
|
||||||
return
|
return
|
||||||
payload = _recv_exact(client_sock, msg_len)
|
payload = _recv_exact(client_sock, msg_len)
|
||||||
@@ -58,25 +60,26 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
|||||||
_log(addr, "?", None, "ERROR", "invalid JSON")
|
_log(addr, "?", None, "ERROR", "invalid JSON")
|
||||||
return
|
return
|
||||||
|
|
||||||
msg_id = msg.get("id")
|
|
||||||
command = msg.get("command", "?")
|
|
||||||
|
|
||||||
# ── user-agent + version check ────────────────────────────────────────────
|
# ── user-agent + version check ────────────────────────────────────────────
|
||||||
|
msg_id = msg.get("id")
|
||||||
ua = msg.get("user_agent") or ""
|
ua = msg.get("user_agent") or ""
|
||||||
if not _UA_PATTERN.match(ua):
|
if not _UA_PATTERN.match(ua):
|
||||||
_send_error(msg_id, "forbidden: client required")
|
_send_error(msg_id, "forbidden: client required")
|
||||||
_log(addr, command, None, "DENIED", f"bad user-agent: {ua!r}")
|
_log(addr, msg.get("command", "?"), None, "DENIED", f"bad user-agent: {ua!r}")
|
||||||
return
|
return
|
||||||
client_ver = "0"
|
client_ver = "0"
|
||||||
try:
|
try:
|
||||||
client_ver = ua.split("/", 1)[1]
|
client_ver = ua.split("/", 1)[1]
|
||||||
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
|
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}")
|
_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}")
|
_log(addr, msg.get("command", "?"), None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
|
||||||
return
|
return
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
msg = adapt_auth(msg, client_ver)
|
||||||
|
command = msg.get("command", "?")
|
||||||
|
|
||||||
# ── auth ──────────────────────────────────────────────────────────────────
|
# ── auth ──────────────────────────────────────────────────────────────────
|
||||||
if auth_keys is not None:
|
if auth_keys is not None:
|
||||||
pub = msg.get("pubkey") or ""
|
pub = msg.get("pubkey") or ""
|
||||||
@@ -101,8 +104,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
|||||||
{"profile": target.profile, "displayName": target.display_name}
|
{"profile": target.profile, "displayName": target.display_name}
|
||||||
for target in active_browser_targets(include_remotes=False)
|
for target in active_browser_targets(include_remotes=False)
|
||||||
]
|
]
|
||||||
data = json.dumps({"id": msg_id, "success": True, "data": targets}).encode()
|
_send_ok(msg_id, targets)
|
||||||
client_sock.sendall(struct.pack("<I", len(data)) + data)
|
|
||||||
_log(addr, command, None, "OK")
|
_log(addr, command, None, "OK")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -113,8 +115,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
|||||||
return
|
return
|
||||||
from browser_cli.auth import load_authorized_keys_with_names
|
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)]
|
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()
|
_send_ok(msg_id, entries)
|
||||||
client_sock.sendall(struct.pack("<I", len(data)) + data)
|
|
||||||
_log(addr, command, None, "OK")
|
_log(addr, command, None, "OK")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -127,13 +128,12 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
|||||||
args = msg.get("args") or {}
|
args = msg.get("args") or {}
|
||||||
pubkey = str(args.get("pubkey") or "")
|
pubkey = str(args.get("pubkey") or "")
|
||||||
name = str(args.get("name") or "")
|
name = str(args.get("name") or "")
|
||||||
if len(pubkey) != 64:
|
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
|
||||||
_send_error(msg_id, "invalid pubkey: expected 64 hex characters")
|
_send_error(msg_id, "invalid pubkey: expected 64 lowercase hex characters")
|
||||||
_log(addr, command, None, "ERROR", "invalid pubkey")
|
_log(addr, command, None, "ERROR", "invalid pubkey")
|
||||||
return
|
return
|
||||||
added = add_authorized_key(auth_keys_path, pubkey, name)
|
added = add_authorized_key(auth_keys_path, pubkey, name)
|
||||||
data = json.dumps({"id": msg_id, "success": True, "data": {"added": added}}).encode()
|
_send_ok(msg_id, {"added": added})
|
||||||
client_sock.sendall(struct.pack("<I", len(data)) + data)
|
|
||||||
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
|
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -158,20 +158,18 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
|||||||
from multiprocessing.connection import Client as PipeClient
|
from multiprocessing.connection import Client as PipeClient
|
||||||
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
||||||
pipe.send_bytes(clean_payload)
|
pipe.send_bytes(clean_payload)
|
||||||
resp = pipe.recv_bytes()
|
resp_payload = pipe.recv_bytes()
|
||||||
resp = adapt_response(resp, command, client_ver)
|
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||||
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
|
_framed_send(client_sock, resp_payload)
|
||||||
else:
|
else:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
|
||||||
local.connect(sock_path)
|
local.connect(sock_path)
|
||||||
local.sendall(clean_header + clean_payload)
|
local.sendall(clean_header + clean_payload)
|
||||||
resp_header = _recv_exact(local, 4)
|
resp_payload = _recv_all(local)
|
||||||
resp_len = struct.unpack("<I", resp_header)[0]
|
|
||||||
resp_payload = _recv_exact(local, resp_len)
|
|
||||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||||
client_sock.sendall(struct.pack("<I", len(resp_payload)) + resp_payload)
|
_framed_send(client_sock, resp_payload)
|
||||||
|
|
||||||
resp_data = json.loads(resp_payload if not is_windows() else resp)
|
resp_data = json.loads(resp_payload)
|
||||||
if resp_data.get("success", True):
|
if resp_data.get("success", True):
|
||||||
_log(addr, command, resolved_profile, "OK")
|
_log(addr, command, resolved_profile, "OK")
|
||||||
else:
|
else:
|
||||||
@@ -201,7 +199,7 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
|
|||||||
"min_client_version": PROTOCOL_MIN_CLIENT,
|
"min_client_version": PROTOCOL_MIN_CLIENT,
|
||||||
}).encode()
|
}).encode()
|
||||||
try:
|
try:
|
||||||
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
|
_framed_send(client_sock, challenge)
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
return
|
||||||
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce)
|
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce)
|
||||||
|
|||||||
@@ -1,45 +1,10 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, BrowserNotConnected
|
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None, profile=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_multi(command, args=None, profile=None, remote=None):
|
|
||||||
try:
|
|
||||||
if remote:
|
|
||||||
return send_command(command, args or {}, profile=profile, remote=remote)
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except (BrowserNotConnected, RuntimeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _multi_browser_targets():
|
|
||||||
root = click.get_current_context().find_root()
|
|
||||||
if root.obj.get("browser_explicit"):
|
|
||||||
return []
|
|
||||||
remote = root.obj.get("remote")
|
|
||||||
key = root.obj.get("key")
|
|
||||||
if remote:
|
|
||||||
targets = remote_browser_targets(remote, key=key)
|
|
||||||
else:
|
|
||||||
targets = active_browser_targets(key=key)
|
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
|
||||||
return []
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
@click.group("session")
|
@click.group("session")
|
||||||
def session_group():
|
def session_group():
|
||||||
"""Save and restore browser sessions."""
|
"""Save and restore browser sessions."""
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import click
|
import click
|
||||||
from browser_cli.client import send_command, BrowserNotConnected
|
from browser_cli.commands import _handle
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {})
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group("storage")
|
@click.group("storage")
|
||||||
def storage_group():
|
def storage_group():
|
||||||
"""Read and write the page's localStorage / sessionStorage."""
|
"""Read and write the page's localStorage / sessionStorage."""
|
||||||
|
|||||||
@@ -1,48 +1,13 @@
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import click
|
import click
|
||||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None, profile=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_multi(command, args=None, profile=None, remote=None):
|
|
||||||
try:
|
|
||||||
if remote:
|
|
||||||
return send_command(command, args or {}, profile=profile, remote=remote)
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except (BrowserNotConnected, RuntimeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _multi_browser_targets():
|
|
||||||
root = click.get_current_context().find_root()
|
|
||||||
if root.obj.get("browser_explicit"):
|
|
||||||
return []
|
|
||||||
remote = root.obj.get("remote")
|
|
||||||
key = root.obj.get("key")
|
|
||||||
if remote:
|
|
||||||
targets = remote_browser_targets(remote, key=key)
|
|
||||||
else:
|
|
||||||
targets = active_browser_targets(key=key)
|
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
|
||||||
return []
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
|
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
|
||||||
if not tabs:
|
if not tabs:
|
||||||
console.print("[yellow]No tabs found[/yellow]")
|
console.print("[yellow]No tabs found[/yellow]")
|
||||||
|
|||||||
@@ -1,46 +1,11 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None, profile=None):
|
|
||||||
try:
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except BrowserNotConnected as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
except RuntimeError as e:
|
|
||||||
console.print(f"[red]Browser error:[/red] {e}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_multi(command, args=None, profile=None, remote=None):
|
|
||||||
try:
|
|
||||||
if remote:
|
|
||||||
return send_command(command, args or {}, profile=profile, remote=remote)
|
|
||||||
return send_command(command, args or {}, profile=profile)
|
|
||||||
except (BrowserNotConnected, RuntimeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _multi_browser_targets():
|
|
||||||
root = click.get_current_context().find_root()
|
|
||||||
if root.obj.get("browser_explicit"):
|
|
||||||
return []
|
|
||||||
remote = root.obj.get("remote")
|
|
||||||
key = root.obj.get("key")
|
|
||||||
if remote:
|
|
||||||
targets = remote_browser_targets(remote, key=key)
|
|
||||||
else:
|
|
||||||
targets = active_browser_targets(key=key)
|
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
|
||||||
return []
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
||||||
if not windows:
|
if not windows:
|
||||||
console.print("[yellow]No windows found[/yellow]")
|
console.print("[yellow]No windows found[/yellow]")
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from browser_cli.compat.commands import adapt_request, adapt_response
|
||||||
|
from browser_cli.compat.auth import adapt_auth
|
||||||
|
|
||||||
|
__all__ = ["adapt_auth", "adapt_request", "adapt_response"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Auth-field normalizers — applied to the raw incoming message *before* the
|
||||||
|
auth check runs. Protocol fields (pubkey, sig, …) are still present here.
|
||||||
|
|
||||||
|
Add one entry per breaking auth-field change:
|
||||||
|
("X.Y.Z", transformer_fn)
|
||||||
|
|
||||||
|
Entries must stay in ascending version order.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Callable
|
||||||
|
from browser_cli.version_manager import parse_version
|
||||||
|
|
||||||
|
|
||||||
|
# ── v0.9.3 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _auth_0_9_3(msg: dict) -> dict:
|
||||||
|
"""pubkey validation tightened to lowercase hex; normalize for older clients."""
|
||||||
|
changed: dict = {}
|
||||||
|
pk = msg.get("pubkey")
|
||||||
|
if isinstance(pk, str) and pk:
|
||||||
|
changed["pubkey"] = pk.lower()
|
||||||
|
if msg.get("command") == "browser-cli.auth.trust":
|
||||||
|
args = msg.get("args") or {}
|
||||||
|
trust_pk = args.get("pubkey")
|
||||||
|
if isinstance(trust_pk, str) and trust_pk:
|
||||||
|
changed["args"] = {**args, "pubkey": trust_pk.lower()}
|
||||||
|
return {**msg, **changed} if changed else msg
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
|
||||||
|
("0.9.3", _auth_0_9_3),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_auth(msg: dict, client_version: str) -> dict:
|
||||||
|
"""Apply all auth normalizers needed to bring msg up to the current format."""
|
||||||
|
cv = parse_version(client_version)
|
||||||
|
for version, fn in _AUTH_COMPAT:
|
||||||
|
if cv < parse_version(version):
|
||||||
|
msg = fn(msg)
|
||||||
|
return msg
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Command-format shims — applied to clean_msg (protocol fields already stripped)
|
||||||
|
before forwarding to the native host, and to responses before sending back.
|
||||||
|
|
||||||
|
Add one entry per breaking command-format change:
|
||||||
|
("X.Y.Z", request_fn, response_fn)
|
||||||
|
|
||||||
|
- request_fn(msg: dict) -> dict or None
|
||||||
|
- response_fn(resp: bytes, command: str) -> bytes or None
|
||||||
|
|
||||||
|
Entries must stay in ascending version order.
|
||||||
|
adapt_request walks forward (oldest first); adapt_response walks backward.
|
||||||
|
|
||||||
|
Current baseline: 0.9.3 — no command-format shims needed yet.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Callable
|
||||||
|
from browser_cli.version_manager import parse_version
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_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 browser command 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 native-host 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
|
||||||
@@ -19,6 +19,7 @@ from multiprocessing.connection import Listener
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
|
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
|
||||||
|
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
|
||||||
from browser_cli.registry import update_registry
|
from browser_cli.registry import update_registry
|
||||||
|
|
||||||
SOCKET_PATH: str = "" # set after hello handshake
|
SOCKET_PATH: str = "" # set after hello handshake
|
||||||
@@ -278,6 +279,8 @@ def _recv_all(conn: socket.socket) -> bytes | None:
|
|||||||
if raw_len is None:
|
if raw_len is None:
|
||||||
return None
|
return None
|
||||||
msg_len = struct.unpack("<I", raw_len)[0]
|
msg_len = struct.unpack("<I", raw_len)[0]
|
||||||
|
if msg_len > _MAX_MSG_BYTES:
|
||||||
|
return None
|
||||||
return _recv_exact(conn, msg_len)
|
return _recv_exact(conn, msg_len)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from importlib.metadata import version as _pkg_version
|
from importlib.metadata import version as _pkg_version
|
||||||
|
|
||||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||||
|
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def parse_version(v: str) -> tuple[int, ...]:
|
def parse_version(v: str) -> tuple[int, ...]:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.2"
|
version = "0.9.3"
|
||||||
description = "Control your real running browser from the terminal via a browser extension"
|
description = "Control your real running browser from the terminal via a browser extension"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from browser_cli.auth import (
|
||||||
|
add_authorized_key,
|
||||||
|
canonical_payload,
|
||||||
|
generate_keypair,
|
||||||
|
load_authorized_keys,
|
||||||
|
load_authorized_keys_with_names,
|
||||||
|
load_private_key,
|
||||||
|
new_nonce,
|
||||||
|
sign,
|
||||||
|
verify,
|
||||||
|
)
|
||||||
|
from browser_cli.client import _is_valid_key_spec
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateKeypair:
|
||||||
|
def test_returns_pem_and_hex(self):
|
||||||
|
pem, pub_hex = generate_keypair()
|
||||||
|
assert pem.startswith(b"-----BEGIN PRIVATE KEY-----")
|
||||||
|
assert len(pub_hex) == 64
|
||||||
|
|
||||||
|
def test_each_call_unique(self):
|
||||||
|
_, pub1 = generate_keypair()
|
||||||
|
_, pub2 = generate_keypair()
|
||||||
|
assert pub1 != pub2
|
||||||
|
|
||||||
|
|
||||||
|
class TestCanonicalPayload:
|
||||||
|
def test_strips_pubkey_and_sig(self):
|
||||||
|
msg = {"command": "tabs.list", "id": "x", "pubkey": "abc", "sig": "def"}
|
||||||
|
data = json.loads(canonical_payload(msg))
|
||||||
|
assert "pubkey" not in data
|
||||||
|
assert "sig" not in data
|
||||||
|
|
||||||
|
def test_keys_sorted(self):
|
||||||
|
msg = {"z": 1, "a": 2, "m": 3}
|
||||||
|
payload = canonical_payload(msg).decode()
|
||||||
|
assert payload.index('"a"') < payload.index('"m"') < payload.index('"z"')
|
||||||
|
|
||||||
|
def test_deterministic(self):
|
||||||
|
msg = {"b": 2, "a": 1}
|
||||||
|
assert canonical_payload(msg) == canonical_payload(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def keypair(tmp_path):
|
||||||
|
pem, pub_hex = generate_keypair()
|
||||||
|
key_path = tmp_path / "client.key.pem"
|
||||||
|
key_path.write_bytes(pem)
|
||||||
|
priv = load_private_key(key_path)
|
||||||
|
return priv, pub_hex
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignVerify:
|
||||||
|
def test_valid_signature_verifies(self, keypair):
|
||||||
|
priv, pub_hex = keypair
|
||||||
|
nonce = bytes.fromhex(new_nonce())
|
||||||
|
msg = {"command": "tabs.list", "id": "uuid-1", "args": {}}
|
||||||
|
sig = sign(priv, nonce, msg).hex()
|
||||||
|
assert verify(pub_hex, nonce, msg, sig) is True
|
||||||
|
|
||||||
|
def test_tampered_sig_fails(self, keypair):
|
||||||
|
priv, pub_hex = keypair
|
||||||
|
nonce = bytes.fromhex(new_nonce())
|
||||||
|
msg = {"command": "tabs.list", "id": "x"}
|
||||||
|
sign(priv, nonce, msg)
|
||||||
|
assert verify(pub_hex, nonce, msg, "00" * 64) is False
|
||||||
|
|
||||||
|
def test_wrong_pubkey_fails(self, keypair):
|
||||||
|
priv, _ = keypair
|
||||||
|
_, other_pub = generate_keypair()
|
||||||
|
nonce = bytes.fromhex(new_nonce())
|
||||||
|
msg = {"command": "tabs.list"}
|
||||||
|
sig = sign(priv, nonce, msg).hex()
|
||||||
|
assert verify(other_pub, nonce, msg, sig) is False
|
||||||
|
|
||||||
|
def test_wrong_nonce_fails(self, keypair):
|
||||||
|
priv, pub_hex = keypair
|
||||||
|
nonce = bytes.fromhex(new_nonce())
|
||||||
|
msg = {"command": "tabs.list"}
|
||||||
|
sig = sign(priv, nonce, msg).hex()
|
||||||
|
other_nonce = bytes.fromhex(new_nonce())
|
||||||
|
assert verify(pub_hex, other_nonce, msg, sig) is False
|
||||||
|
|
||||||
|
def test_garbage_pub_hex_returns_false_not_exception(self):
|
||||||
|
assert verify("not-hex!!!!", b"nonce", {}, "00" * 64) is False
|
||||||
|
|
||||||
|
def test_truncated_sig_hex_returns_false_not_exception(self, keypair):
|
||||||
|
_, pub_hex = keypair
|
||||||
|
assert verify(pub_hex, b"nonce", {}, "aabb") is False
|
||||||
|
|
||||||
|
def test_wrong_length_pubkey_returns_false_not_exception(self):
|
||||||
|
assert verify("aabbcc", b"nonce", {}, "00" * 64) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorizedKeys:
|
||||||
|
def test_add_and_load(self, tmp_path):
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
_, pub = generate_keypair()
|
||||||
|
assert add_authorized_key(path, pub, "alice") is True
|
||||||
|
assert pub in load_authorized_keys(path)
|
||||||
|
|
||||||
|
def test_add_duplicate_returns_false(self, tmp_path):
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
_, pub = generate_keypair()
|
||||||
|
add_authorized_key(path, pub)
|
||||||
|
assert add_authorized_key(path, pub) is False
|
||||||
|
|
||||||
|
def test_load_with_names(self, tmp_path):
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
_, pub1 = generate_keypair()
|
||||||
|
_, pub2 = generate_keypair()
|
||||||
|
add_authorized_key(path, pub1, "alice")
|
||||||
|
add_authorized_key(path, pub2)
|
||||||
|
entries = load_authorized_keys_with_names(path)
|
||||||
|
assert (pub1, "alice") in entries
|
||||||
|
assert (pub2, "") in entries
|
||||||
|
|
||||||
|
def test_ignores_comment_lines(self, tmp_path):
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
path.write_text("# this is a comment\n")
|
||||||
|
assert load_authorized_keys(path) == []
|
||||||
|
|
||||||
|
def test_returns_empty_for_missing_file(self, tmp_path):
|
||||||
|
assert load_authorized_keys(tmp_path / "nofile") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsValidKeySpec:
|
||||||
|
def test_agent_bare(self):
|
||||||
|
assert _is_valid_key_spec("agent") is True
|
||||||
|
|
||||||
|
def test_agent_with_selector(self):
|
||||||
|
assert _is_valid_key_spec("agent:cardno:000012345678") is True
|
||||||
|
|
||||||
|
def test_absolute_pem_path(self):
|
||||||
|
assert _is_valid_key_spec("/home/user/.config/browser-cli/client.key.pem") is True
|
||||||
|
|
||||||
|
def test_dot_key_extension(self):
|
||||||
|
assert _is_valid_key_spec("/tmp/mykey.key") is True
|
||||||
|
|
||||||
|
def test_angled_bracket_pem_rejected(self):
|
||||||
|
# regression: operator precedence bug allowed "<garbage>.pem" to pass
|
||||||
|
assert _is_valid_key_spec("<garbage>.pem") is False
|
||||||
|
|
||||||
|
def test_angled_bracket_key_rejected(self):
|
||||||
|
assert _is_valid_key_spec("<garbage>.key") is False
|
||||||
|
|
||||||
|
def test_serialized_object_rejected(self):
|
||||||
|
assert _is_valid_key_spec("<AgentKey(blob=b'...', comment='test')>.pem") is False
|
||||||
|
|
||||||
|
def test_empty_string_rejected(self):
|
||||||
|
assert _is_valid_key_spec("") is False
|
||||||
|
|
||||||
|
def test_bare_filename_no_slash_no_ext_rejected(self):
|
||||||
|
assert _is_valid_key_spec("mykey") is False
|
||||||
+28
-28
@@ -262,12 +262,12 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
|||||||
return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
|
return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.tabs.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
return_value=[
|
return_value=[
|
||||||
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"),
|
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"),
|
||||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||||
],
|
],
|
||||||
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
|
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -278,13 +278,13 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
|||||||
|
|
||||||
def test_tabs_list_with_remote_uses_only_remote_targets():
|
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.tabs.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||||
), patch(
|
), patch(
|
||||||
"browser_cli.commands.tabs.remote_browser_targets",
|
"browser_cli.commands.remote_browser_targets",
|
||||||
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
|
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
|
||||||
), patch(
|
), patch(
|
||||||
"browser_cli.commands.tabs.send_command",
|
"browser_cli.commands.send_command",
|
||||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||||
) as send_command:
|
) as send_command:
|
||||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
||||||
@@ -297,13 +297,13 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
|
|||||||
|
|
||||||
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.tabs.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
return_value=[
|
return_value=[
|
||||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||||
],
|
],
|
||||||
), patch(
|
), patch(
|
||||||
"browser_cli.commands.tabs.send_command",
|
"browser_cli.commands.send_command",
|
||||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}],
|
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}],
|
||||||
) as send_command:
|
) as send_command:
|
||||||
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
|
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
|
||||||
@@ -322,12 +322,12 @@ def test_tabs_count_multi_browser_shows_total():
|
|||||||
return counts[profile]
|
return counts[profile]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.tabs.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
return_value=[
|
return_value=[
|
||||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||||
],
|
],
|
||||||
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
|
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||||
result = CliRunner().invoke(main, ["tabs", "count", "github"])
|
result = CliRunner().invoke(main, ["tabs", "count", "github"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -344,12 +344,12 @@ def test_group_count_multi_browser_shows_total():
|
|||||||
return counts[profile]
|
return counts[profile]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.groups.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
return_value=[
|
return_value=[
|
||||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||||
],
|
],
|
||||||
), patch("browser_cli.commands.groups.send_command", side_effect=fake_send_command):
|
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||||
result = CliRunner().invoke(main, ["groups", "count"])
|
result = CliRunner().invoke(main, ["groups", "count"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -360,7 +360,7 @@ def test_group_count_multi_browser_shows_total():
|
|||||||
|
|
||||||
def test_group_list_leaves_unnamed_group_cell_empty():
|
def test_group_list_leaves_unnamed_group_cell_empty():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.groups.send_command",
|
"browser_cli.commands.send_command",
|
||||||
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
|
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
|
||||||
):
|
):
|
||||||
result = CliRunner().invoke(main, ["groups", "list"])
|
result = CliRunner().invoke(main, ["groups", "list"])
|
||||||
@@ -372,7 +372,7 @@ def test_group_list_leaves_unnamed_group_cell_empty():
|
|||||||
|
|
||||||
|
|
||||||
def test_tabs_move_accepts_right_short_alias():
|
def test_tabs_move_accepts_right_short_alias():
|
||||||
with patch("browser_cli.commands.tabs.send_command") as send_command:
|
with patch("browser_cli.commands.send_command") as send_command:
|
||||||
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
|
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -384,7 +384,7 @@ def test_tabs_move_accepts_right_short_alias():
|
|||||||
|
|
||||||
|
|
||||||
def test_groups_move_accepts_left_short_alias():
|
def test_groups_move_accepts_left_short_alias():
|
||||||
with patch("browser_cli.commands.groups.send_command") as send_command:
|
with patch("browser_cli.commands.send_command") as send_command:
|
||||||
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
|
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -399,12 +399,12 @@ def test_windows_list_multi_browser_shows_browser_column():
|
|||||||
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.windows.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
return_value=[
|
return_value=[
|
||||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||||
],
|
],
|
||||||
), patch("browser_cli.commands.windows.send_command", side_effect=fake_send_command):
|
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||||
result = CliRunner().invoke(main, ["windows", "list"])
|
result = CliRunner().invoke(main, ["windows", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -420,12 +420,12 @@ def test_session_list_multi_browser_shows_browser_column():
|
|||||||
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.session.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
return_value=[
|
return_value=[
|
||||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||||
],
|
],
|
||||||
), patch("browser_cli.commands.session.send_command", side_effect=fake_send_command):
|
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||||
result = CliRunner().invoke(main, ["session", "list"])
|
result = CliRunner().invoke(main, ["session", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -438,13 +438,13 @@ def test_session_list_multi_browser_shows_browser_column():
|
|||||||
|
|
||||||
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.session.active_browser_targets",
|
"browser_cli.commands.active_browser_targets",
|
||||||
return_value=[
|
return_value=[
|
||||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||||
],
|
],
|
||||||
), patch(
|
), patch(
|
||||||
"browser_cli.commands.session.send_command",
|
"browser_cli.commands.send_command",
|
||||||
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
|
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
|
||||||
) as send_command:
|
) as send_command:
|
||||||
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
|
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
|
||||||
@@ -455,7 +455,7 @@ def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
|||||||
|
|
||||||
|
|
||||||
def test_windows_open_passes_url():
|
def test_windows_open_passes_url():
|
||||||
with patch("browser_cli.commands.windows.send_command", return_value={"id": 7}) as send_command:
|
with patch("browser_cli.commands.send_command", return_value={"id": 7}) as send_command:
|
||||||
result = CliRunner().invoke(main, ["windows", "open", "https://example.com"])
|
result = CliRunner().invoke(main, ["windows", "open", "https://example.com"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -463,20 +463,20 @@ def test_windows_open_passes_url():
|
|||||||
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
||||||
|
|
||||||
def test_extract_markdown_command():
|
def test_extract_markdown_command():
|
||||||
with patch("browser_cli.commands.extract.send_command", return_value="# Title") as send_command:
|
with patch("browser_cli.commands.send_command", return_value="# Title") as send_command:
|
||||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output == "# Title\n"
|
assert result.output == "# Title\n"
|
||||||
send_command.assert_called_once_with("extract.markdown", {"selector": None})
|
send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
|
||||||
|
|
||||||
def test_extract_markdown_command_with_selector():
|
def test_extract_markdown_command_with_selector():
|
||||||
with patch("browser_cli.commands.extract.send_command", return_value="## Post") as send_command:
|
with patch("browser_cli.commands.send_command", return_value="## Post") as send_command:
|
||||||
result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"])
|
result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output == "## Post\n"
|
assert result.output == "## Post\n"
|
||||||
send_command.assert_called_once_with("extract.markdown", {"selector": "article"})
|
send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
|
||||||
|
|
||||||
|
|
||||||
def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
|
def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
|
||||||
@@ -561,7 +561,7 @@ def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
|
|||||||
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n"
|
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n"
|
||||||
"```"
|
"```"
|
||||||
)
|
)
|
||||||
with patch("browser_cli.commands.extract.send_command", return_value=raw):
|
with patch("browser_cli.commands.send_command", return_value=raw):
|
||||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -639,8 +639,8 @@ def test_tabs_list_multi_browser_queries_remote_target():
|
|||||||
remote=endpoint,
|
remote=endpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("browser_cli.commands.tabs.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
with patch("browser_cli.commands.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||||
"browser_cli.commands.tabs.send_command",
|
"browser_cli.commands.send_command",
|
||||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||||
) as send_command:
|
) as send_command:
|
||||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Unit tests for the TCP serve layer (challenge-response auth, framing, rejection paths)."""
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, sign
|
||||||
|
from browser_cli.client import BrowserNotConnected
|
||||||
|
from browser_cli.commands.serve import _handle_client
|
||||||
|
|
||||||
|
FAKE_UA = "browser-cli/0.9.3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
||||||
|
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||||
|
|
||||||
|
|
||||||
|
def _recv_framed(sock: socket.socket) -> dict:
|
||||||
|
raw = b""
|
||||||
|
while len(raw) < 4:
|
||||||
|
chunk = sock.recv(4 - len(raw))
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("socket closed before response header")
|
||||||
|
raw += chunk
|
||||||
|
n = struct.unpack("<I", raw)[0]
|
||||||
|
data = b""
|
||||||
|
while len(data) < n:
|
||||||
|
chunk = sock.recv(n - len(data))
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("socket closed mid-response")
|
||||||
|
data += chunk
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||||
|
t = threading.Thread(
|
||||||
|
target=_handle_client,
|
||||||
|
args=(server_sock, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _pair():
|
||||||
|
return socket.socketpair()
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_no_browser(*_args, **_kwargs):
|
||||||
|
raise BrowserNotConnected("no browser")
|
||||||
|
|
||||||
|
|
||||||
|
# ── challenge frame ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestChallenge:
|
||||||
|
def test_challenge_sent_on_connect(self):
|
||||||
|
client, server = _pair()
|
||||||
|
t = _spawn(server, None)
|
||||||
|
challenge = _recv_framed(client)
|
||||||
|
assert challenge["type"] == "challenge"
|
||||||
|
assert "nonce" in challenge
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_challenge_includes_version_fields(self):
|
||||||
|
client, server = _pair()
|
||||||
|
t = _spawn(server, None)
|
||||||
|
challenge = _recv_framed(client)
|
||||||
|
assert "server_version" in challenge
|
||||||
|
assert "min_client_version" in challenge
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── rejection paths ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestRejection:
|
||||||
|
def _connect(self, auth_keys_path):
|
||||||
|
client, server = _pair()
|
||||||
|
t = _spawn(server, auth_keys_path)
|
||||||
|
challenge = _recv_framed(client)
|
||||||
|
return client, t, challenge
|
||||||
|
|
||||||
|
def test_bad_user_agent_rejected(self):
|
||||||
|
client, t, _ = self._connect(None)
|
||||||
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "curl/7.88"}
|
||||||
|
_send_framed(client, json.dumps(msg).encode())
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
assert resp["success"] is False
|
||||||
|
assert "forbidden" in resp["error"].lower() or "client" in resp["error"].lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_missing_pubkey_sig_rejected(self, tmp_path):
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
_, pub = generate_keypair()
|
||||||
|
path.write_text(pub + "\n")
|
||||||
|
client, t, _ = self._connect(path)
|
||||||
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||||
|
_send_framed(client, json.dumps(msg).encode())
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
assert resp["success"] is False
|
||||||
|
assert "unauthorized" in resp["error"].lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_untrusted_pubkey_rejected(self, tmp_path):
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
_, trusted_pub = generate_keypair()
|
||||||
|
path.write_text(trusted_pub + "\n")
|
||||||
|
|
||||||
|
pem, untrusted_pub = generate_keypair()
|
||||||
|
key_path = tmp_path / "other.pem"
|
||||||
|
key_path.write_bytes(pem)
|
||||||
|
priv = load_private_key(key_path)
|
||||||
|
|
||||||
|
client, t, challenge = self._connect(path)
|
||||||
|
nonce = bytes.fromhex(challenge["nonce"])
|
||||||
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": untrusted_pub}
|
||||||
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||||
|
_send_framed(client, json.dumps(msg).encode())
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
assert resp["success"] is False
|
||||||
|
assert "untrusted" in resp["error"].lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_bad_signature_rejected(self, tmp_path):
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
_, pub = generate_keypair()
|
||||||
|
path.write_text(pub + "\n")
|
||||||
|
client, t, _ = self._connect(path)
|
||||||
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub, "sig": "00" * 64}
|
||||||
|
_send_framed(client, json.dumps(msg).encode())
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
assert resp["success"] is False
|
||||||
|
assert "signature" in resp["error"].lower() or "invalid" in resp["error"].lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_oversized_message_rejected(self):
|
||||||
|
client, server = _pair()
|
||||||
|
t = _spawn(server, None)
|
||||||
|
_recv_framed(client) # consume challenge
|
||||||
|
client.sendall(struct.pack("<I", 33 * 1024 * 1024))
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
assert resp["success"] is False
|
||||||
|
assert "too large" in resp["error"].lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_invalid_json_rejected(self):
|
||||||
|
client, server = _pair()
|
||||||
|
t = _spawn(server, None)
|
||||||
|
_recv_framed(client) # consume challenge
|
||||||
|
bad = b"this is not json {"
|
||||||
|
_send_framed(client, bad)
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
assert resp["success"] is False
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── auth success paths ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestAuthSuccess:
|
||||||
|
def test_valid_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||||
|
"""Correct signature → error must be 'browser not connected', not 'unauthorized'."""
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
pem, pub = generate_keypair()
|
||||||
|
path.write_text(pub + "\n")
|
||||||
|
key_path = tmp_path / "client.key.pem"
|
||||||
|
key_path.write_bytes(pem)
|
||||||
|
priv = load_private_key(key_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||||
|
|
||||||
|
client, server = _pair()
|
||||||
|
t = threading.Thread(
|
||||||
|
target=_handle_client,
|
||||||
|
args=(server, ("127.0.0.1", 9999), None, path),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
challenge = _recv_framed(client)
|
||||||
|
nonce = bytes.fromhex(challenge["nonce"])
|
||||||
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
|
||||||
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||||
|
_send_framed(client, json.dumps(msg).encode())
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
|
||||||
|
assert resp["success"] is False
|
||||||
|
assert "unauthorized" not in resp["error"].lower()
|
||||||
|
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
|
||||||
|
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
|
||||||
|
path = tmp_path / "authorized_keys"
|
||||||
|
pem, pub = generate_keypair() # pub is lowercase hex
|
||||||
|
path.write_text(pub + "\n")
|
||||||
|
key_path = tmp_path / "client.key.pem"
|
||||||
|
key_path.write_bytes(pem)
|
||||||
|
priv = load_private_key(key_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||||
|
|
||||||
|
client, server = _pair()
|
||||||
|
t = _spawn(server, path)
|
||||||
|
|
||||||
|
challenge = _recv_framed(client)
|
||||||
|
nonce = bytes.fromhex(challenge["nonce"])
|
||||||
|
# old client sends uppercase pubkey
|
||||||
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
|
||||||
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||||
|
_send_framed(client, json.dumps(msg).encode())
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
|
||||||
|
assert "unauthorized" not in resp.get("error", "").lower()
|
||||||
|
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
def test_no_auth_mode_reaches_proxy(self, monkeypatch):
|
||||||
|
"""auth_keys_path=None (--no-auth): no pubkey required, reaches proxy layer."""
|
||||||
|
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||||
|
|
||||||
|
client, server = _pair()
|
||||||
|
t = threading.Thread(
|
||||||
|
target=_handle_client,
|
||||||
|
args=(server, ("127.0.0.1", 9999), None, None),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
_recv_framed(client) # challenge
|
||||||
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||||
|
_send_framed(client, json.dumps(msg).encode())
|
||||||
|
resp = _recv_framed(client)
|
||||||
|
|
||||||
|
assert "unauthorized" not in resp.get("error", "").lower()
|
||||||
|
client.close()
|
||||||
|
t.join(timeout=2)
|
||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.2"
|
version = "0.9.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user