refactor: reorganize client transport and extension internals
- Split client, native, remote, serve, markdown, and SDK internals into focused packages with direct imports. - Move local and remote transport framing/protocol helpers behind clearer module boundaries. - Break up the extension injected DOM logic into a separate content dispatch bundle and dedicated content modules. - Add explicit client handling for passive remote discovery without noisy PQ warnings. - Keep behavior covered with updated unit, integration, and extension tests.
This commit is contained in:
@@ -13,11 +13,10 @@ from rich.table import Table
|
||||
|
||||
from browser_cli import BrowserCLI, BrowserCounts
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.constants import GENTLE_MODES
|
||||
|
||||
_console = Console()
|
||||
|
||||
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
|
||||
|
||||
# Reusable ``--tab`` option: select a tab by ID (default: the active tab).
|
||||
tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Click commands for browser-cli remote authentication keys."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
@click.group("auth")
|
||||
def auth_group():
|
||||
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
|
||||
|
||||
@auth_group.command("keygen")
|
||||
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
|
||||
@click.option("--force", is_flag=True, help="Overwrite existing key.")
|
||||
def cmd_auth_keygen(output, force):
|
||||
"""Generate an Ed25519 keypair for pubkey auth."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair
|
||||
|
||||
key_path = Path(output) if output else DEFAULT_KEY_PATH
|
||||
if key_path.exists() and not force:
|
||||
console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)")
|
||||
sys.exit(1)
|
||||
pem, pub_hex = generate_keypair()
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(pem)
|
||||
console.print(f"[green]✓[/green] Private key: {key_path}")
|
||||
console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]")
|
||||
console.print("\nOn the serve host, trust this key:")
|
||||
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
|
||||
|
||||
@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).")
|
||||
@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:
|
||||
console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)")
|
||||
sys.exit(1)
|
||||
try:
|
||||
bytes.fromhex(pubkey)
|
||||
except ValueError:
|
||||
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, name)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f" File: {path}")
|
||||
console.print("\nStart the server with:")
|
||||
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
||||
|
||||
@auth_group.command("show")
|
||||
@click.option(
|
||||
"--key",
|
||||
"key_src",
|
||||
default=None,
|
||||
metavar="PATH|agent[:<selector>]",
|
||||
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.",
|
||||
)
|
||||
def cmd_auth_show(key_src):
|
||||
"""Print the Ed25519 public key that browser-cli will use for auth."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex
|
||||
|
||||
src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
|
||||
|
||||
if src == "agent" or src.startswith("agent:"):
|
||||
selector = src[6:] or None
|
||||
key = agent_find_key(selector)
|
||||
if key is None:
|
||||
console.print("[red]No Ed25519 key found in SSH agent.[/red]")
|
||||
console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.")
|
||||
sys.exit(1)
|
||||
console.print(f"[dim]source:[/dim] agent ({key.comment})")
|
||||
console.print(public_key_hex(key))
|
||||
return
|
||||
|
||||
path = Path(src)
|
||||
if not path.exists():
|
||||
console.print(f"[red]No key found at {path}[/red]")
|
||||
console.print(" Run: [dim]browser-cli auth keygen[/dim]")
|
||||
console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]")
|
||||
sys.exit(1)
|
||||
try:
|
||||
priv = load_private_key(path)
|
||||
console.print(f"[dim]source:[/dim] {path}")
|
||||
console.print(public_key_hex(priv))
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to load key:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@auth_group.command("keys")
|
||||
@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
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Click commands for inspecting connected browser clients."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.client import (
|
||||
BrowserNotConnected,
|
||||
REGISTRY_PATH,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
remote_browser_targets,
|
||||
remote_target_for_alias,
|
||||
send_command,
|
||||
)
|
||||
from browser_cli.registry import load_registry
|
||||
|
||||
console = Console()
|
||||
|
||||
def _rename_target_profile(target_browser: str | None) -> str | None:
|
||||
if target_browser:
|
||||
return target_browser
|
||||
|
||||
active = active_browser_targets()
|
||||
if len(active) == 1:
|
||||
return active[0].profile
|
||||
return None
|
||||
|
||||
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
|
||||
target_profile = _rename_target_profile(target_browser)
|
||||
profiles: dict[str, str] = load_registry(REGISTRY_PATH)
|
||||
|
||||
if alias in profiles and alias != target_profile:
|
||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||
|
||||
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False):
|
||||
"""Query clients.list for one target and append each, tagged with *label*."""
|
||||
if quiet_remote_warning:
|
||||
result = send_command(
|
||||
"clients.list",
|
||||
profile=profile,
|
||||
remote=remote,
|
||||
key=key,
|
||||
suppress_pq_warning=True,
|
||||
)
|
||||
else:
|
||||
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = label
|
||||
into.append(c)
|
||||
|
||||
@click.group("clients", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def clients_group(ctx):
|
||||
"""Inspect and manage connected browser clients."""
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
all_clients = []
|
||||
|
||||
browser_alias = (ctx.obj or {}).get("browser")
|
||||
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
key = (ctx.obj or {}).get("key")
|
||||
|
||||
if not remote and browser_alias:
|
||||
_collect_remote_alias_clients(all_clients, browser_alias, key)
|
||||
elif remote:
|
||||
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key)
|
||||
else:
|
||||
_collect_local_and_saved_remote_clients(all_clients)
|
||||
|
||||
if not all_clients:
|
||||
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
|
||||
sys.exit(1)
|
||||
|
||||
_print_clients(all_clients)
|
||||
|
||||
def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) -> None:
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if not resolved:
|
||||
return
|
||||
try:
|
||||
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:
|
||||
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
def _collect_explicit_remote_clients(all_clients: list, browser_alias: str | None, remote: str, key) -> None:
|
||||
try:
|
||||
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)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
||||
profiles: dict[str, str] = load_registry(REGISTRY_PATH) if REGISTRY_PATH.exists() else {}
|
||||
|
||||
for profile_name, sock_path in profiles.items():
|
||||
display_profile = display_browser_name(profile_name, sock_path)
|
||||
try:
|
||||
_append_clients(all_clients, display_profile, profile=profile_name)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
all_clients.append({
|
||||
"profile": display_profile,
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
|
||||
targets = active_browser_targets(suppress_pq_warning=True)
|
||||
|
||||
for target in targets:
|
||||
if target.remote is None:
|
||||
continue
|
||||
try:
|
||||
_append_clients(
|
||||
all_clients,
|
||||
target.display_name,
|
||||
profile=target.profile,
|
||||
remote=target.remote,
|
||||
quiet_remote_warning=True,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
def _print_clients(all_clients: list) -> None:
|
||||
from rich.table import Table
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Profile")
|
||||
table.add_column("Browser")
|
||||
table.add_column("Version")
|
||||
table.add_column("Extension Version")
|
||||
for c in all_clients:
|
||||
table.add_row(
|
||||
c.get("profile", ""),
|
||||
c.get("name", ""),
|
||||
c.get("version", ""),
|
||||
c.get("extensionVersion", ""),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
@clients_group.command("rename")
|
||||
@click.option(
|
||||
"--browser",
|
||||
"target_browser",
|
||||
default=None,
|
||||
metavar="ALIAS",
|
||||
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
|
||||
)
|
||||
@click.argument("alias")
|
||||
def cmd_clients_rename(target_browser, alias):
|
||||
"""Set the profile alias used to identify this browser instance."""
|
||||
try:
|
||||
_ensure_unique_browser_alias(alias, target_browser)
|
||||
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
console.print(f"[green]Profile renamed to '{alias}'[/green]")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Shell completion command."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
@click.command("completion")
|
||||
@click.argument("shell", type=click.Choice(["zsh", "bash", "fish"]))
|
||||
@click.option("--script", is_flag=True, help="Output the raw completion script instead of instructions")
|
||||
@click.pass_context
|
||||
def cmd_completion(ctx, shell, script):
|
||||
"""Print shell completion setup instructions (or output the script with --script)."""
|
||||
if script:
|
||||
from click.shell_completion import BashComplete, FishComplete, ZshComplete
|
||||
cls = {"zsh": ZshComplete, "bash": BashComplete, "fish": FishComplete}[shell]
|
||||
comp = cls(ctx.find_root().command, {}, "browser-cli", "_BROWSER_CLI_COMPLETE")
|
||||
click.echo(comp.source())
|
||||
return
|
||||
|
||||
exe = sys.executable.replace("/python", "/browser-cli").replace("/python3", "/browser-cli")
|
||||
if not Path(exe).exists():
|
||||
exe = "browser-cli"
|
||||
|
||||
env_var = "_BROWSER_CLI_COMPLETE"
|
||||
|
||||
if shell == "zsh":
|
||||
console.print("[bold]Quickest setup — generate the file once:[/bold]")
|
||||
console.print()
|
||||
console.print(" [cyan]uv run browser-cli completion zsh --script > ~/.zfunc/_browser-cli[/cyan]")
|
||||
console.print()
|
||||
console.print(" Then add these lines to [bold]~/.zshrc[/bold] (before any compinit call):")
|
||||
console.print(" [cyan]fpath=(~/.zfunc $fpath)[/cyan]")
|
||||
console.print(" [cyan]autoload -Uz compinit && compinit[/cyan]")
|
||||
console.print()
|
||||
console.print(" Reload: [cyan]exec zsh[/cyan]")
|
||||
console.print()
|
||||
console.print("[bold]Alternative — eval on every shell start (simpler but slower):[/bold]")
|
||||
console.print(f' [cyan]eval "$({env_var}=zsh_source {exe})"[/cyan]')
|
||||
elif shell == "bash":
|
||||
console.print("[bold]Quickest setup — generate the file once:[/bold]")
|
||||
console.print()
|
||||
console.print(" [cyan]uv run browser-cli completion bash --script > ~/.bash_completion.d/browser-cli[/cyan]")
|
||||
console.print()
|
||||
console.print(" Reload: [cyan]source ~/.bashrc[/cyan]")
|
||||
console.print()
|
||||
console.print("[bold]Alternative — eval on every shell start:[/bold]")
|
||||
console.print(f' [cyan]eval "$({env_var}=bash_source {exe})"[/cyan]')
|
||||
elif shell == "fish":
|
||||
console.print("[bold]Setup:[/bold]")
|
||||
console.print()
|
||||
console.print(" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]")
|
||||
@@ -2,9 +2,6 @@ import json
|
||||
|
||||
import click
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
# Re-exported for backward compatibility: the HTML→Markdown engine now lives in
|
||||
# browser_cli.markdown and is applied by the SDK (ExtractNS.markdown).
|
||||
from browser_cli.markdown import _clean_markdown_output, _convert_html_to_markdown # noqa: F401
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Native Messaging host installation command."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.constants import (
|
||||
EXTENSION_ID,
|
||||
NATIVE_HOST_DIRS,
|
||||
NATIVE_HOST_NAME,
|
||||
SUPPORTED_BROWSERS,
|
||||
WINDOWS_NATIVE_HOST_REGISTRY_KEYS,
|
||||
)
|
||||
from browser_cli.platform import install_base_dir, is_windows
|
||||
|
||||
console = Console()
|
||||
|
||||
def native_host_exe() -> Path:
|
||||
base = install_base_dir()
|
||||
if is_windows():
|
||||
return base / "libexec" / "browser-cli-native-host.cmd"
|
||||
return base / "libexec" / "browser-cli-native-host"
|
||||
|
||||
def write_native_host_exe(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if is_windows():
|
||||
path.write_text(
|
||||
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native.host import main; main()" %*\r\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
path.write_text(f'#!{sys.executable}\nfrom browser_cli.native.host import main\nmain()\n')
|
||||
path.chmod(path.stat().st_mode | 0o111)
|
||||
|
||||
def _windows_registry_views():
|
||||
import winreg
|
||||
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
|
||||
|
||||
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
|
||||
import winreg
|
||||
|
||||
installed = []
|
||||
for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]:
|
||||
full_key = f"{key_path}\\{NATIVE_HOST_NAME}"
|
||||
for view in _windows_registry_views():
|
||||
try:
|
||||
access = winreg.KEY_WRITE | view
|
||||
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access)
|
||||
with key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path))
|
||||
installed.append(f"HKCU\\{full_key}")
|
||||
except OSError as e:
|
||||
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
|
||||
return installed
|
||||
|
||||
@click.command("install")
|
||||
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
|
||||
def cmd_install(browser):
|
||||
"""Register the native messaging host and print extension load instructions."""
|
||||
host_exe = native_host_exe()
|
||||
write_native_host_exe(host_exe)
|
||||
|
||||
ext_url = {
|
||||
"chrome": "chrome://extensions",
|
||||
"chromium": "chrome://extensions",
|
||||
"brave": "brave://extensions",
|
||||
"edge": "edge://extensions",
|
||||
"vivaldi": "vivaldi://extensions",
|
||||
}[browser]
|
||||
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
||||
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
||||
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
|
||||
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
|
||||
console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
|
||||
|
||||
manifest = {
|
||||
"name": NATIVE_HOST_NAME,
|
||||
"description": "browser-cli native messaging host",
|
||||
"path": str(host_exe),
|
||||
"type": "stdio",
|
||||
"allowed_origins": [f"chrome-extension://{EXTENSION_ID}/"],
|
||||
}
|
||||
installed = _install_manifest(browser, host_exe, manifest)
|
||||
if not installed:
|
||||
console.print("[red]Failed to install native host manifest[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
for p in installed:
|
||||
label = "Registered native host" if is_windows() else "Wrote native host manifest"
|
||||
console.print(f"[green]✓[/green] {label}: {p}")
|
||||
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
||||
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||
|
||||
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
|
||||
if is_windows():
|
||||
manifest_dir = host_exe.parent
|
||||
manifest_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
return _register_windows_native_host(browser, manifest_path)
|
||||
|
||||
platform = "darwin" if sys.platform == "darwin" else "linux"
|
||||
installed = []
|
||||
for directory in NATIVE_HOST_DIRS[browser][platform]:
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = directory / f"{NATIVE_HOST_NAME}.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||
installed.append(manifest_path)
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Could not write to {directory}: {e}[/yellow]")
|
||||
return installed
|
||||
+95
-316
@@ -1,338 +1,117 @@
|
||||
import re, threading, secrets, socket, struct, click, json, sys
|
||||
from datetime import datetime
|
||||
"""Click command for exposing a browser over TCP."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
import click
|
||||
|
||||
from browser_cli import transport
|
||||
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")
|
||||
_CONN_LIMIT = threading.BoundedSemaphore(64)
|
||||
console = Console()
|
||||
|
||||
def _framed_send(sock: socket.socket, data: bytes) -> None:
|
||||
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
addr_str = f"{addr[0]}:{addr[1]}"
|
||||
profile_str = f"[dim]{profile}[/dim] " if profile else ""
|
||||
if error:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
||||
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, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None, compress:bool=True) -> None:
|
||||
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
||||
from browser_cli.platform import is_windows
|
||||
|
||||
response_secret = None
|
||||
accept_encoding = None # set once the (decrypted) request is parsed; None → plain JSON
|
||||
|
||||
def _send_payload(data: bytes) -> None:
|
||||
if response_secret is not None:
|
||||
from browser_cli.auth import pq_encrypt
|
||||
data = json.dumps({"encrypted": pq_encrypt(response_secret, "response", data)}).encode()
|
||||
_framed_send(client_sock, data)
|
||||
|
||||
def _send_error(msg_id, msg:str) -> None:
|
||||
# errors stay plain JSON: tiny, and safe for any client
|
||||
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
|
||||
try:
|
||||
_send_payload(err)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _send_ok(msg_id, payload, command=None) -> None:
|
||||
obj = {"id": msg_id, "success": True, "data": payload}
|
||||
try:
|
||||
_send_payload(transport.encode_response(obj, accept_encoding if compress else None, command))
|
||||
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):
|
||||
_send_error(None, "invalid JSON")
|
||||
_log(addr, "?", None, "ERROR", "invalid JSON")
|
||||
return
|
||||
|
||||
# ── user-agent + version check ────────────────────────────────────────────
|
||||
msg_id = msg.get("id")
|
||||
ua = msg.get("user_agent") or ""
|
||||
if not _UA_PATTERN.match(ua):
|
||||
_send_error(msg_id, "forbidden: client required")
|
||||
_log(addr, msg.get("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, msg.get("command", "?"), None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
|
||||
return
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
msg = adapt_auth(msg, client_ver)
|
||||
command = msg.get("command", "?")
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────
|
||||
if auth_keys is not None:
|
||||
pub = msg.get("pubkey") or ""
|
||||
sig = msg.get("sig") or ""
|
||||
if not pub or not sig:
|
||||
_send_error(msg_id, "unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
|
||||
_log(addr, command, None, "DENIED", "missing pubkey/sig")
|
||||
return
|
||||
if pub not in auth_keys:
|
||||
_send_error(msg_id, "unauthorized: untrusted public key")
|
||||
_log(addr, command, None, "DENIED", "untrusted key")
|
||||
return
|
||||
pq_shared_secret = None
|
||||
transport_encrypted = False
|
||||
if pq_private_key is not None:
|
||||
kex = msg.get("pq_kex") or {}
|
||||
pq_required = parse_version(client_ver) >= parse_version("0.9.5")
|
||||
if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
|
||||
if pq_required:
|
||||
_send_error(msg_id, "unauthorized: post-quantum key exchange required")
|
||||
_log(addr, command, None, "DENIED", "missing pq kex")
|
||||
return
|
||||
else:
|
||||
try:
|
||||
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
|
||||
pq_shared_secret = pq_kex_server_decapsulate(pq_private_key, str(kex["ciphertext"]))
|
||||
if "encrypted" in msg:
|
||||
decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
|
||||
if not isinstance(decrypted_msg, dict):
|
||||
raise ValueError("encrypted request is not a JSON object")
|
||||
decrypted_msg["pubkey"] = pub
|
||||
decrypted_msg["sig"] = sig
|
||||
decrypted_msg["pq_kex"] = kex
|
||||
msg = adapt_auth(decrypted_msg, client_ver)
|
||||
msg_id = msg.get("id", msg_id)
|
||||
command = msg.get("command", "?")
|
||||
transport_encrypted = True
|
||||
elif pq_required:
|
||||
_send_error(msg_id, "unauthorized: post-quantum encrypted transport required")
|
||||
_log(addr, command, None, "DENIED", "missing pq transport")
|
||||
return
|
||||
except Exception:
|
||||
_send_error(msg_id, "unauthorized: invalid post-quantum encrypted transport")
|
||||
_log(addr, command, None, "DENIED", "bad pq transport")
|
||||
return
|
||||
|
||||
from browser_cli.auth import verify
|
||||
if not verify(pub, bytes.fromhex(nonce), msg, sig, pq_shared_secret):
|
||||
_send_error(msg_id, "unauthorized: invalid signature")
|
||||
_log(addr, command, None, "DENIED", "bad signature")
|
||||
return
|
||||
response_secret = pq_shared_secret if transport_encrypted else None
|
||||
|
||||
# client advertises what response encodings it can decode (signed, then stripped)
|
||||
accept_encoding = msg.get("accept_encoding")
|
||||
|
||||
if command == "browser-cli.targets":
|
||||
from browser_cli.client import active_browser_targets
|
||||
targets = [
|
||||
{"profile": target.profile, "displayName": target.display_name}
|
||||
for target in active_browser_targets(include_remotes=False)
|
||||
]
|
||||
_send_ok(msg_id, targets, command)
|
||||
_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)]
|
||||
_send_ok(msg_id, entries, command)
|
||||
_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 not re.fullmatch(r"[0-9a-f]{64}", pubkey):
|
||||
_send_error(msg_id, "invalid pubkey: expected 64 lowercase hex characters")
|
||||
_log(addr, command, None, "ERROR", "invalid pubkey")
|
||||
return
|
||||
added = add_authorized_key(auth_keys_path, pubkey, name)
|
||||
_send_ok(msg_id, {"added": added}, command)
|
||||
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||
return
|
||||
|
||||
resolved_profile = msg.get("_route") or profile
|
||||
|
||||
# ── strip protocol fields, apply request compat shim, forward ─────────────
|
||||
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
|
||||
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)
|
||||
except BrowserNotConnected as e:
|
||||
_send_error(msg_id, str(e))
|
||||
_log(addr, command, resolved_profile, "ERROR", "browser not connected")
|
||||
return
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
||||
pipe.send_bytes(clean_payload)
|
||||
resp_payload = pipe.recv_bytes()
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
else:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
|
||||
local.connect(sock_path)
|
||||
local.sendall(clean_header + clean_payload)
|
||||
resp_payload = _recv_all(local)
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
|
||||
# parse once: drives both the access log and (re-)encoding for the client
|
||||
resp_data = json.loads(resp_payload)
|
||||
if compress:
|
||||
_send_payload(transport.encode_response(resp_data, accept_encoding, command))
|
||||
else:
|
||||
_send_payload(resp_payload)
|
||||
if resp_data.get("success", True):
|
||||
_log(addr, command, resolved_profile, "OK")
|
||||
else:
|
||||
_log(addr, command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_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, auth_keys_path:"Path|None", compress:bool=True) -> 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)
|
||||
pq_private_key = None
|
||||
challenge_msg = {
|
||||
"type": "challenge",
|
||||
"nonce": nonce,
|
||||
"server_version": get_installed_version(),
|
||||
"min_client_version": PROTOCOL_MIN_CLIENT,
|
||||
}
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
|
||||
pq_keypair = pq_kex_server_keypair()
|
||||
if pq_keypair is not None:
|
||||
pq_private_key, pq_public_key = pq_keypair
|
||||
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
|
||||
challenge = json.dumps(challenge_msg).encode()
|
||||
try:
|
||||
_framed_send(client_sock, challenge)
|
||||
except OSError:
|
||||
return
|
||||
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
|
||||
finally:
|
||||
_CONN_LIMIT.release()
|
||||
from browser_cli.serve.runtime import (
|
||||
_async_framed_send,
|
||||
_async_handle_client,
|
||||
_async_recv_all,
|
||||
_handle_client,
|
||||
_serve_async,
|
||||
console,
|
||||
)
|
||||
from browser_cli.version_manager import get_installed_version
|
||||
|
||||
__all__ = [
|
||||
"_async_framed_send",
|
||||
"_async_handle_client",
|
||||
"_async_recv_all",
|
||||
"_handle_client",
|
||||
"_serve_async",
|
||||
"cmd_serve",
|
||||
]
|
||||
|
||||
@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("--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). Required unless --no-auth.")
|
||||
@click.option("--no-compress", "no_compress", is_flag=True, default=False,
|
||||
help="Disable response compression / msgpack even for clients that support it.")
|
||||
@click.option(
|
||||
"--authorized-keys",
|
||||
"auth_keys_file",
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-compress",
|
||||
"no_compress",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable response compression / msgpack even for clients that support it.",
|
||||
)
|
||||
@click.pass_context
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
compress = not no_compress
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
compress = not no_compress
|
||||
|
||||
if host in ("0.0.0.0", "::"):
|
||||
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
|
||||
if host in ("0.0.0.0", "::"):
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Binding to all interfaces — "
|
||||
"anyone who can reach this port controls your browser."
|
||||
)
|
||||
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
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_path = None
|
||||
else:
|
||||
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
|
||||
sys.exit(1)
|
||||
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
|
||||
if auth_keys_path is False:
|
||||
sys.exit(1)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
server.bind((host, port))
|
||||
except OSError as e:
|
||||
server.close()
|
||||
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||
sys.exit(1)
|
||||
server.listen(16)
|
||||
_print_startup(host, port, profile, auth_keys_path, compress)
|
||||
|
||||
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] [dim]v{current_ver}[/dim]")
|
||||
try:
|
||||
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
|
||||
except OSError as e:
|
||||
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
console.print("[yellow]Stopped.[/yellow]")
|
||||
|
||||
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
|
||||
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}")
|
||||
return auth_keys_path
|
||||
if no_auth:
|
||||
return None
|
||||
console.print(
|
||||
"[red]Error:[/red] --authorized-keys FILE is required. "
|
||||
"Use --no-auth to explicitly disable auth (dangerous)."
|
||||
)
|
||||
return False
|
||||
|
||||
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
|
||||
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] [dim]v{current_ver}[/dim]")
|
||||
|
||||
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 ''})")
|
||||
else:
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
|
||||
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]")
|
||||
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]")
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
_print_encoding_status(compress)
|
||||
console.print("Ctrl-C to stop.\n")
|
||||
|
||||
if compress:
|
||||
def _print_encoding_status(compress: bool) -> None:
|
||||
if not compress:
|
||||
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
|
||||
return
|
||||
codecs = "+".join(transport.supported_compression())
|
||||
sers = "+".join(transport.supported_serialization())
|
||||
console.print(f" Encode: [green]on[/green] [dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]")
|
||||
else:
|
||||
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
|
||||
|
||||
console.print("Ctrl-C to stop.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path, compress), daemon=True).start()
|
||||
except KeyboardInterrupt:
|
||||
console.print("[yellow]Stopped.[/yellow]")
|
||||
finally:
|
||||
server.close()
|
||||
console.print(
|
||||
" Encode: [green]on[/green] "
|
||||
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user