feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
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:
+29
-19
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user