feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
Testing / test (push) Successful in 26s
Package Extension / package-extension (push) Successful in 22s
Build & Publish Package / publish (push) Successful in 27s

Security:
- serve.py: server now sends nonce challenge before accepting any command;
  clients sign nonce + SHA256(canonical_payload) with Ed25519 key
- New --authorized-keys FILE option for serve; token auth still works as fallback
- Connection limit: BoundedSemaphore(64) in serve.py
- Secure file creation with os.open(..., 0o600) for token/key files
- New auth.py module: keygen, file key load/save, SSH agent protocol (pure Python),
  sign/verify helpers compatible with both file keys and agent-held keys (YubiKey,
  TPM, gpg-agent)

Features:
- YubiKey support via SSH agent protocol — no new runtime deps, just $SSH_AUTH_SOCK
- New `browser-cli auth` command group: keygen, trust, show, keys
- Global --key PATH flag (or BROWSER_CLI_KEY env) selects signing key;
  pass "agent" or "agent:<selector>" to use SSH agent key
- BrowserCLI Python API gains key= parameter

Bug fixes (11 issues across two review passes):
- client.py: check response is not None before json.loads
- native_host.py: _read_exact_stream loop handles EINTR short reads; fix Windows
  Listener leak on accept error
- __init__.py: open_wait / tabs_watch_url raise RuntimeError instead of silent None
- extension/tabs.ts: dedupe skips tabs without URL; tabsSort uses pendingUrl fallback
- extension/session.ts: removeListener before addListener prevents duplicate handlers

Breaking: TCP serve protocol now sends a challenge frame first (v0.9.0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:20:39 +02:00
parent 9f03e29807
commit 4b2abbbfc5
14 changed files with 735 additions and 121 deletions
+29 -19
View File
@@ -18,6 +18,7 @@ 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
@@ -33,7 +34,7 @@ class BrowserCounts:
class BrowserCLI:
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None):
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None, key: str | None = None):
"""
Args:
browser: Profile alias to target. Required when multiple browser
@@ -42,14 +43,18 @@ 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.
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.
"""
self._browser = browser
self._remote = remote
self._token = token
self._key = Path(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)
return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token, key=self._key)
def _multi_browser_targets(self):
if self._browser is not None:
@@ -64,10 +69,11 @@ class BrowserCLI:
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
for target in self._multi_browser_targets():
targets = self._multi_browser_targets()
for target in targets:
try:
if target.remote:
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token)
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token, key=self._key)
else:
data = send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
@@ -75,7 +81,7 @@ class BrowserCLI:
results.append((target, data))
if results:
return results
if self._multi_browser_targets():
if targets:
raise BrowserNotConnected(
"Cannot resolve a browser socket automatically.\n"
"Make sure the browser is running with the browser-cli extension enabled,\n"
@@ -173,7 +179,9 @@ class BrowserCLI:
"url": url, "timeout": int(timeout * 1000),
"background": background, "window": window, "group": group,
})
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("navigate.open_wait returned unexpected data")
return self._make_tab(data)
def wait_for_load(
self,
@@ -291,7 +299,9 @@ class BrowserCLI:
) -> "Tab":
"""Block until the tab URL matches regex pattern. Returns the Tab."""
data = self._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("tabs.watch_url returned unexpected data")
return self._make_tab(data)
def tabs_screenshot(
self,
@@ -440,7 +450,7 @@ class BrowserCLI:
for target, windows in multi_results
for window in (windows or [])
]
return self._cmd("windows.list", {})
return self._cmd("windows.list", {}) or []
def windows_rename(self, window_id: int, name: str) -> None:
self._cmd("windows.rename", {"windowId": window_id, "name": name})
@@ -450,12 +460,12 @@ class BrowserCLI:
def windows_open(self, url: str | None = None) -> dict:
"""Open a new browser window, optionally on a URL."""
return self._cmd("windows.open", {"url": url})
return self._cmd("windows.open", {"url": url}) or {}
# ── DOM ───────────────────────────────────────────────────────────────
def dom_query(self, selector: str) -> list[dict]:
return self._cmd("dom.query", {"selector": selector})
return self._cmd("dom.query", {"selector": selector}) or []
def dom_click(self, selector: str) -> None:
self._cmd("dom.click", {"selector": selector})
@@ -464,13 +474,13 @@ class BrowserCLI:
self._cmd("dom.type", {"selector": selector, "text": text})
def dom_attr(self, selector: str, attr: str) -> list[str]:
return self._cmd("dom.attr", {"selector": selector, "attr": attr})
return self._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
def dom_text(self, selector: str) -> list[str]:
return self._cmd("dom.text", {"selector": selector})
return self._cmd("dom.text", {"selector": selector}) or []
def dom_exists(self, selector: str) -> bool:
return self._cmd("dom.exists", {"selector": selector})
return self._cmd("dom.exists", {"selector": selector}) or False
def dom_scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
"""Scroll to a CSS selector or to pixel coordinates."""
@@ -630,13 +640,13 @@ class BrowserCLI:
# ── Extract ───────────────────────────────────────────────────────────
def extract_links(self) -> list[dict]:
return self._cmd("extract.links", {})
return self._cmd("extract.links", {}) or []
def extract_images(self) -> list[dict]:
return self._cmd("extract.images", {})
return self._cmd("extract.images", {}) or []
def extract_text(self) -> str:
return self._cmd("extract.text", {})
return self._cmd("extract.text", {}) or ""
def extract_json(self, selector: str):
return self._cmd("extract.json", {"selector": selector})
@@ -653,7 +663,7 @@ class BrowserCLI:
self._cmd("session.load", {"name": name})
def session_diff(self, name_a: str, name_b: str) -> dict:
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b})
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
def session_list(self) -> list[dict]:
"""Return saved sessions.
@@ -667,7 +677,7 @@ class BrowserCLI:
for target, sessions in multi_results
for session in (sessions or [])
]
return self._cmd("session.list", {})
return self._cmd("session.list", {}) or []
def session_remove(self, name: str) -> None:
self._cmd("session.remove", {"name": name})