Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eaa1469143
|
|||
|
f79ff0e3c2
|
|||
|
a8b433aa29
|
|||
|
94c87e244b
|
|||
|
9096efd36a
|
|||
|
98396a7c7e
|
|||
|
30a42ba6d5
|
|||
|
533e9d328d
|
|||
|
9177e989bd
|
|||
|
7fd966014f
|
|||
|
217641d0ef
|
|||
|
0d5c49c19a
|
|||
|
c1a5ef9dd7
|
|||
|
b98c4ae116
|
|||
|
fcd2e8b87b
|
|||
|
b87f536ecd
|
|||
|
a2aa031d71
|
|||
|
8593916e5a
|
@@ -21,3 +21,27 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
|
||||
remote-protocol-compat:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser-cli-client-version:
|
||||
- "0.9.3"
|
||||
- "0.9.5"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev --managed-python
|
||||
|
||||
- name: Run remote protocol compatibility matrix
|
||||
env:
|
||||
BROWSER_CLI_COMPAT_CLIENT_VERSION: ${{ matrix.browser-cli-client-version }}
|
||||
run: uv run pytest tests/test_remote_protocol_matrix.py -v
|
||||
|
||||
+8
-17
@@ -18,7 +18,6 @@ Usage:
|
||||
"""
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||
from browser_cli.models import Group, Tab
|
||||
@@ -34,7 +33,7 @@ class BrowserCounts:
|
||||
|
||||
|
||||
class BrowserCLI:
|
||||
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None, key: str | None = None):
|
||||
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
|
||||
"""
|
||||
Args:
|
||||
browser: Profile alias to target. Required when multiple browser
|
||||
@@ -43,24 +42,22 @@ class BrowserCLI:
|
||||
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
|
||||
Can be combined with ``browser`` to route to a specific
|
||||
remote profile.
|
||||
token: Auth token for the remote serve instance (legacy token auth).
|
||||
key: Path to Ed25519 private key PEM for pubkey auth. When set,
|
||||
overrides token auth. Defaults to ``~/.config/browser-cli/client.key.pem``
|
||||
if that file exists.
|
||||
key: Path to Ed25519 private key PEM for pubkey auth, or ``"agent"``
|
||||
to use a key from the SSH agent (YubiKey, gpg-agent, etc.).
|
||||
Defaults to ``~/.config/browser-cli/client.key.pem`` if that file exists.
|
||||
"""
|
||||
self._browser = browser
|
||||
self._remote = remote
|
||||
self._token = token
|
||||
self._key = Path(key) if key else None
|
||||
self._key = key if key else None
|
||||
|
||||
def _cmd(self, command: str, args: dict | None = None):
|
||||
return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token, key=self._key)
|
||||
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
|
||||
|
||||
def _multi_browser_targets(self):
|
||||
if self._browser is not None:
|
||||
return []
|
||||
if self._remote:
|
||||
targets = remote_browser_targets(self._remote, self._token)
|
||||
targets = remote_browser_targets(self._remote, key=self._key)
|
||||
else:
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
@@ -73,7 +70,7 @@ class BrowserCLI:
|
||||
for target in targets:
|
||||
try:
|
||||
if target.remote:
|
||||
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token, key=self._key)
|
||||
data = send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
|
||||
else:
|
||||
data = send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
@@ -98,7 +95,6 @@ class BrowserCLI:
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_token: str | None = None,
|
||||
) -> Tab:
|
||||
tab = Tab(
|
||||
id=data["id"],
|
||||
@@ -113,7 +109,6 @@ class BrowserCLI:
|
||||
tab._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
token=browser_token,
|
||||
)
|
||||
return tab
|
||||
|
||||
@@ -124,7 +119,6 @@ class BrowserCLI:
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_token: str | None = None,
|
||||
) -> Group:
|
||||
group = Group(
|
||||
id=data["id"],
|
||||
@@ -137,7 +131,6 @@ class BrowserCLI:
|
||||
group._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
token=browser_token,
|
||||
)
|
||||
return group
|
||||
|
||||
@@ -237,7 +230,6 @@ class BrowserCLI:
|
||||
browser_profile=target.profile,
|
||||
browser_name=target.display_name,
|
||||
browser_remote=target.remote,
|
||||
browser_token=target.token,
|
||||
)
|
||||
for target, tabs in multi_results
|
||||
for tab in (tabs or [])
|
||||
@@ -392,7 +384,6 @@ class BrowserCLI:
|
||||
browser_profile=target.profile,
|
||||
browser_name=target.display_name,
|
||||
browser_remote=target.remote,
|
||||
browser_token=target.token,
|
||||
)
|
||||
for target, groups in multi_results
|
||||
for group in (groups or [])
|
||||
|
||||
+106
-19
@@ -1,4 +1,4 @@
|
||||
"""Ed25519 keypair management and challenge-response auth helpers."""
|
||||
"""Ed25519 keypair management, ML-KEM key exchange, and auth helpers."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -10,6 +10,9 @@ from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
@@ -103,6 +106,8 @@ def agent_find_key(selector: str | None = None) -> AgentKey | None:
|
||||
except Exception:
|
||||
return None
|
||||
for key in keys:
|
||||
if key.comment == "(none)":
|
||||
continue
|
||||
if selector is None or selector in key.comment:
|
||||
return key
|
||||
return None
|
||||
@@ -150,54 +155,136 @@ def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
||||
# ── Canonical payload + sign/verify ───────────────────────────────────────────
|
||||
|
||||
def canonical_payload(msg: dict) -> bytes:
|
||||
"""Deterministic JSON encoding of msg without auth fields."""
|
||||
"""Deterministic JSON encoding of msg without auth protocol fields."""
|
||||
return json.dumps(
|
||||
{k: v for k, v in msg.items() if k not in {"pubkey", "sig"}},
|
||||
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict) -> bytes:
|
||||
"""Sign nonce + SHA256(canonical_payload(msg)) — works for both file keys and agent keys."""
|
||||
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
||||
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
|
||||
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
||||
if pq_shared_secret is not None:
|
||||
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
|
||||
return data
|
||||
|
||||
|
||||
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
||||
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
|
||||
data = _auth_message(nonce, msg, pq_shared_secret)
|
||||
if isinstance(key, AgentKey):
|
||||
return agent_sign_raw(key, data)
|
||||
return key.sign(data)
|
||||
|
||||
|
||||
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str) -> bool:
|
||||
"""Return True if sig_hex is a valid Ed25519 signature over the canonical payload."""
|
||||
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
|
||||
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
|
||||
try:
|
||||
pub_bytes = bytes.fromhex(pub_hex)
|
||||
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
|
||||
message = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
||||
pub_key.verify(bytes.fromhex(sig_hex), message)
|
||||
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
|
||||
return True
|
||||
except (InvalidSignature, Exception):
|
||||
except (InvalidSignature, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
|
||||
|
||||
PQ_KEX_ALG = "ML-KEM-768"
|
||||
PQ_TRANSPORT_ALG = "ML-KEM-768+ChaCha20Poly1305"
|
||||
|
||||
|
||||
def pq_kex_server_keypair():
|
||||
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
|
||||
|
||||
Returns ``None`` when the installed cryptography/OpenSSL backend does not
|
||||
support ML-KEM yet. The serve/client protocol treats this as graceful
|
||||
downgrade instead of breaking local installs on older OpenSSL builds.
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric import mlkem
|
||||
priv = mlkem.MLKEM768PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes_raw()
|
||||
return priv, pub
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
|
||||
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
|
||||
from cryptography.hazmat.primitives.asymmetric import mlkem
|
||||
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
||||
shared_secret, ciphertext = pub.encapsulate()
|
||||
return ciphertext.hex(), shared_secret
|
||||
|
||||
|
||||
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
|
||||
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
|
||||
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
|
||||
|
||||
|
||||
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=None,
|
||||
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
|
||||
).derive(shared_secret)
|
||||
|
||||
|
||||
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
|
||||
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
|
||||
nonce = secrets.token_bytes(12)
|
||||
key = _pq_transport_key(shared_secret, direction)
|
||||
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
|
||||
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
|
||||
|
||||
|
||||
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
|
||||
"""Decrypt an app-layer frame produced by pq_encrypt()."""
|
||||
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
|
||||
raise ValueError("unsupported encrypted transport envelope")
|
||||
key = _pq_transport_key(shared_secret, direction)
|
||||
return ChaCha20Poly1305(key).decrypt(
|
||||
bytes.fromhex(str(envelope["nonce"])),
|
||||
bytes.fromhex(str(envelope["ciphertext"])),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def load_authorized_keys(path: Path) -> list[str]:
|
||||
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
||||
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
||||
if not path.exists():
|
||||
return []
|
||||
return [
|
||||
line.strip()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.startswith("#")
|
||||
]
|
||||
result = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
pubkey = parts[0]
|
||||
name = parts[1].strip() if len(parts) > 1 else ""
|
||||
result.append((pubkey, name))
|
||||
return result
|
||||
|
||||
|
||||
def add_authorized_key(path: Path, pub_hex: str) -> bool:
|
||||
def load_authorized_keys(path: Path) -> list[str]:
|
||||
return [pk for pk, _ in load_authorized_keys_with_names(path)]
|
||||
|
||||
|
||||
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
||||
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = set(load_authorized_keys(path))
|
||||
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
|
||||
if pub_hex in existing:
|
||||
return False
|
||||
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(pub_hex + "\n")
|
||||
f.write(line)
|
||||
return True
|
||||
|
||||
+60
-49
@@ -30,7 +30,6 @@ from browser_cli.client import (
|
||||
REGISTRY_PATH,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
save_remote_token,
|
||||
remote_target_for_alias,
|
||||
remote_browser_targets,
|
||||
)
|
||||
@@ -188,19 +187,15 @@ def _print_version(ctx, param, value):
|
||||
help="Browser profile alias to target (required when multiple browsers are active).",
|
||||
)
|
||||
@click.option(
|
||||
"--remote", default=None, metavar="HOST:PORT",
|
||||
help="Connect to a remote browser exposed via 'browser-cli serve'.",
|
||||
)
|
||||
@click.option(
|
||||
"--token", default=None, metavar="TOKEN",
|
||||
help="Auth token for the remote browser-cli serve instance.",
|
||||
"--remote", default=None, metavar="HOST[:PORT]",
|
||||
help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.",
|
||||
)
|
||||
@click.option(
|
||||
"--key", default=None, metavar="PATH",
|
||||
help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx, browser, remote, token, key):
|
||||
def main(ctx, browser, remote, key):
|
||||
"""Control your running browser from the terminal via a Chrome extension."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["browser"] = browser
|
||||
@@ -209,13 +204,10 @@ def main(ctx, browser, remote, token, key):
|
||||
os.environ["BROWSER_CLI_PROFILE"] = browser
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
|
||||
ctx.obj["remote"] = remote
|
||||
ctx.obj["token"] = token
|
||||
ctx.obj["key"] = key
|
||||
if remote:
|
||||
os.environ["BROWSER_CLI_REMOTE"] = remote
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None))
|
||||
if token:
|
||||
save_remote_token(remote, token)
|
||||
if key:
|
||||
os.environ["BROWSER_CLI_KEY"] = key
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
|
||||
@@ -252,9 +244,11 @@ def cmd_auth_keygen(output, force):
|
||||
|
||||
@auth_group.command("trust")
|
||||
@click.argument("pubkey")
|
||||
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
def cmd_auth_trust(pubkey, keys_file):
|
||||
"""Add a public key to the authorized keys file on the serve host."""
|
||||
@click.pass_context
|
||||
def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
||||
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
|
||||
|
||||
if len(pubkey) != 64:
|
||||
@@ -266,10 +260,28 @@ def cmd_auth_trust(pubkey, keys_file):
|
||||
console.print("[red]Invalid public key:[/red] not valid hex")
|
||||
sys.exit(1)
|
||||
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
result = send_command(
|
||||
"browser-cli.auth.trust",
|
||||
args={"pubkey": pubkey, "name": name},
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
)
|
||||
added = (result or {}).get("added", False)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
|
||||
return
|
||||
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
added = add_authorized_key(path, pubkey)
|
||||
added = add_authorized_key(path, pubkey, name)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f" File: {path}")
|
||||
console.print(f"\nStart the server with:")
|
||||
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
||||
@@ -313,39 +325,40 @@ def cmd_auth_show(key_src):
|
||||
|
||||
|
||||
@auth_group.command("keys")
|
||||
def cmd_auth_keys():
|
||||
"""List all Ed25519 keys available for pubkey auth (file + SSH agent)."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, agent_list_keys, load_private_key, public_key_hex
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@click.pass_context
|
||||
def cmd_auth_keys(ctx, keys_file):
|
||||
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Source")
|
||||
table.add_column("Comment / Path")
|
||||
table.add_column("Public Key")
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
result = send_command(
|
||||
"browser-cli.auth.keys",
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
)
|
||||
entries = result or []
|
||||
source_label = remote
|
||||
else:
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
|
||||
source_label = str(path)
|
||||
|
||||
# File key
|
||||
if DEFAULT_KEY_PATH.exists():
|
||||
try:
|
||||
priv = load_private_key(DEFAULT_KEY_PATH)
|
||||
hex_key = public_key_hex(priv)
|
||||
table.add_row("[green]file[/green]", str(DEFAULT_KEY_PATH), hex_key)
|
||||
except Exception as e:
|
||||
table.add_row("[red]file[/red]", str(DEFAULT_KEY_PATH), f"[red]{e}[/red]")
|
||||
|
||||
# Agent keys
|
||||
try:
|
||||
agent_keys = agent_list_keys()
|
||||
for k in agent_keys:
|
||||
table.add_row("[cyan]agent[/cyan]", k.comment, public_key_hex(k))
|
||||
except Exception as e:
|
||||
table.add_row("[dim]agent[/dim]", f"[dim]{e}[/dim]", "")
|
||||
|
||||
if table.row_count == 0:
|
||||
console.print("[yellow]No keys found.[/yellow] Run: [dim]browser-cli auth keygen[/dim]")
|
||||
if not entries:
|
||||
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
|
||||
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
|
||||
return
|
||||
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Public Key")
|
||||
for entry in entries:
|
||||
name = entry.get("name") or "[dim]—[/dim]"
|
||||
table.add_row(name, entry.get("pubkey", ""))
|
||||
console.print(table)
|
||||
console.print("\nTo trust a key on the serve host:")
|
||||
console.print(" [dim]browser-cli auth trust <public-key>[/dim]")
|
||||
|
||||
|
||||
main.add_command(auth_group)
|
||||
@@ -378,7 +391,6 @@ def clients_group(ctx):
|
||||
|
||||
browser_alias = (ctx.obj or {}).get("browser")
|
||||
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
token = (ctx.obj or {}).get("token") or os.environ.get("BROWSER_CLI_TOKEN")
|
||||
key = (ctx.obj or {}).get("key")
|
||||
|
||||
if not remote and browser_alias:
|
||||
@@ -386,15 +398,14 @@ def clients_group(ctx):
|
||||
# then show ALL clients from that remote (not just the one resolved profile).
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if resolved:
|
||||
resolved_token = token or resolved.token
|
||||
try:
|
||||
targets = remote_browser_targets(resolved.remote, resolved_token)
|
||||
targets = remote_browser_targets(resolved.remote)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
for target in targets:
|
||||
try:
|
||||
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, token=resolved_token, key=key)
|
||||
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = target.display_name
|
||||
all_clients.append(c)
|
||||
@@ -402,7 +413,7 @@ def clients_group(ctx):
|
||||
continue
|
||||
elif remote:
|
||||
try:
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, token=token, key=key)
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = c.get("profile") or browser_alias or "remote"
|
||||
all_clients.append(c)
|
||||
@@ -434,7 +445,7 @@ def clients_group(ctx):
|
||||
if target.remote is None:
|
||||
continue
|
||||
try:
|
||||
result = send_command("clients.list", profile=target.profile, remote=target.remote, token=target.token)
|
||||
result = send_command("clients.list", profile=target.profile, remote=target.remote)
|
||||
for c in (result or []):
|
||||
c["profile"] = target.display_name
|
||||
all_clients.append(c)
|
||||
|
||||
+165
-43
@@ -10,8 +10,10 @@ Profile selection order:
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
@@ -19,12 +21,52 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
|
||||
except Exception:
|
||||
_USER_AGENT = "browser-cli/0"
|
||||
|
||||
REGISTRY_PATH = registry_path()
|
||||
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
|
||||
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
|
||||
|
||||
_DEFAULT_REMOTE_PORT = 443
|
||||
|
||||
|
||||
def _looks_like_domain(host: str) -> bool:
|
||||
"""True if host looks like a domain name rather than an IP address or localhost."""
|
||||
if host in {"localhost", "127.0.0.1", "::1"}:
|
||||
return False
|
||||
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
|
||||
return False
|
||||
return '.' in host and any(c.isalpha() for c in host)
|
||||
|
||||
|
||||
def _normalize_endpoint(endpoint: str) -> str:
|
||||
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
|
||||
if not endpoint:
|
||||
return endpoint
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
if sep and port == "443" and _looks_like_domain(host):
|
||||
return host
|
||||
return endpoint
|
||||
|
||||
|
||||
def _resolve_connect_endpoint(endpoint: str) -> str:
|
||||
"""Return host:port for TCP connection; domain without port defaults to :443."""
|
||||
_, sep, _ = endpoint.rpartition(":")
|
||||
if not sep:
|
||||
if _looks_like_domain(endpoint):
|
||||
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
|
||||
raise BrowserNotConnected(
|
||||
f"Invalid remote endpoint '{endpoint}': expected host:port"
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
class BrowserNotConnected(Exception):
|
||||
"""Raised when the native host socket is not available."""
|
||||
@@ -36,7 +78,6 @@ class BrowserTarget:
|
||||
display_name: str
|
||||
socket_path: str
|
||||
remote: str | None = None
|
||||
token: str | None = None
|
||||
|
||||
|
||||
def _active_endpoints(reg: dict) -> dict:
|
||||
@@ -61,16 +102,25 @@ def _load_remotes() -> dict[str, dict[str, str]]:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
|
||||
# normalize keys so old entries stored as "domain:443" match current lookups
|
||||
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
|
||||
|
||||
|
||||
def save_remote_token(endpoint: str, token: str | None) -> None:
|
||||
"""Persist the auth token for a remote endpoint used by this client."""
|
||||
if not endpoint or not token:
|
||||
|
||||
def _is_valid_key_spec(s: str) -> bool:
|
||||
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
|
||||
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and ("/" in s or Path(s).suffix in {".pem", ".key"}))
|
||||
|
||||
|
||||
def save_remote_key(endpoint: str, key_spec: str) -> None:
|
||||
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
|
||||
if not endpoint or not key_spec:
|
||||
return
|
||||
if not _is_valid_key_spec(key_spec):
|
||||
return # refuse to save serialized objects or other garbage
|
||||
remotes = _load_remotes()
|
||||
current = remotes.get(endpoint, {})
|
||||
current["token"] = token
|
||||
current["key"] = key_spec
|
||||
remotes[endpoint] = current
|
||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
@@ -78,23 +128,32 @@ def save_remote_token(endpoint: str, token: str | None) -> None:
|
||||
f.write(json.dumps(remotes, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def token_for_remote(endpoint: str | None) -> str | None:
|
||||
def key_for_remote(endpoint: str | None) -> str | None:
|
||||
if not endpoint:
|
||||
return None
|
||||
cfg = _load_remotes().get(endpoint) or {}
|
||||
token = cfg.get("token")
|
||||
return str(token) if token else None
|
||||
key = cfg.get("key")
|
||||
if not key:
|
||||
return None
|
||||
key_str = str(key)
|
||||
# reject corrupted values (e.g. str(AgentKey(...)) saved by an older bug)
|
||||
if not _is_valid_key_spec(key_str):
|
||||
return None
|
||||
return key_str
|
||||
|
||||
|
||||
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
remote_name = host if sep and port == "8765" else endpoint
|
||||
return f"{remote_name}:{display_name or profile_name}"
|
||||
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
|
||||
display_endpoint = host
|
||||
else:
|
||||
display_endpoint = endpoint # normalized domain (no port) or non-default port
|
||||
return f"{display_endpoint}:{display_name or profile_name}"
|
||||
|
||||
|
||||
def remote_browser_targets(endpoint: str, token: str | None = None) -> list[BrowserTarget]:
|
||||
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
"""Return browser targets advertised by a single remote endpoint."""
|
||||
remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token)
|
||||
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
|
||||
targets: list[BrowserTarget] = []
|
||||
for item in remote_targets or []:
|
||||
profile = str(item.get("profile") or "default")
|
||||
@@ -105,18 +164,16 @@ def remote_browser_targets(endpoint: str, token: str | None = None) -> list[Brow
|
||||
display_name=_remote_display_name(endpoint, profile, display),
|
||||
socket_path="",
|
||||
remote=endpoint,
|
||||
token=token,
|
||||
)
|
||||
)
|
||||
return targets
|
||||
|
||||
|
||||
def _remote_browser_targets() -> list[BrowserTarget]:
|
||||
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint, cfg in _load_remotes().items():
|
||||
token = str(cfg.get("token") or "") or None
|
||||
for endpoint in _load_remotes():
|
||||
try:
|
||||
targets.extend(remote_browser_targets(endpoint, token))
|
||||
targets.extend(remote_browser_targets(endpoint, key=key))
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
return targets
|
||||
@@ -144,7 +201,7 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
return None
|
||||
|
||||
|
||||
def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]:
|
||||
def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
if REGISTRY_PATH.exists():
|
||||
reg = load_registry(REGISTRY_PATH)
|
||||
@@ -153,7 +210,7 @@ def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarge
|
||||
for profile, sock_path in _active_endpoints(reg).items()
|
||||
)
|
||||
if include_remotes:
|
||||
targets.extend(_remote_browser_targets())
|
||||
targets.extend(_remote_browser_targets(key=key))
|
||||
return targets
|
||||
|
||||
|
||||
@@ -221,12 +278,23 @@ def _load_private_key(key_path: "Path | str | None" = None):
|
||||
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
host, _, port_str = endpoint.rpartition(":")
|
||||
if not host or not port_str:
|
||||
raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(30)
|
||||
sock.connect((host, int(port_str)))
|
||||
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
port = int(port_str)
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(30)
|
||||
try:
|
||||
raw_sock.connect((host, port))
|
||||
if port == 443:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
|
||||
else:
|
||||
sock = raw_sock
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
with sock:
|
||||
|
||||
# receive challenge
|
||||
challenge_raw = _recv_all(sock)
|
||||
@@ -238,21 +306,68 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
nonce_hex = None
|
||||
|
||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||
if min_ver:
|
||||
from browser_cli.version_manager import parse_version
|
||||
try:
|
||||
client_ver = _USER_AGENT.split("/", 1)[1]
|
||||
if parse_version(client_ver) < parse_version(min_ver):
|
||||
raise BrowserNotConnected(
|
||||
f"Client version {client_ver} is too old for this server "
|
||||
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
pq_shared_secret = None
|
||||
if nonce_hex and private_key is not None:
|
||||
from browser_cli.auth import sign, public_key_hex
|
||||
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig"}}
|
||||
sig = sign(private_key, nonce, clean_msg)
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
|
||||
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
|
||||
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
||||
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
|
||||
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
||||
else:
|
||||
sys.stderr.write(
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
||||
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
|
||||
if pq_shared_secret is not None:
|
||||
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
||||
msg = {
|
||||
"id": clean_msg.get("id"),
|
||||
"user_agent": clean_msg.get("user_agent"),
|
||||
"pubkey": public_key_hex(private_key),
|
||||
"sig": sig.hex(),
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": encrypted,
|
||||
}
|
||||
else:
|
||||
sys.stderr.write(
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
sock.sendall(framed)
|
||||
return _recv_all(sock)
|
||||
response = _recv_all(sock)
|
||||
if response is not None and pq_shared_secret is not None:
|
||||
try:
|
||||
from browser_cli.auth import pq_decrypt
|
||||
envelope = json.loads(response)
|
||||
if isinstance(envelope, dict) and "encrypted" in envelope:
|
||||
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
|
||||
except Exception as e:
|
||||
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
|
||||
return response
|
||||
|
||||
|
||||
def _auto_route_remote(endpoint: str, token: str | None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, token)
|
||||
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
return targets[0].profile
|
||||
if len(targets) > 1:
|
||||
@@ -265,10 +380,12 @@ def _auto_route_remote(endpoint: str, token: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None, key: "Path | None" = None) -> Any:
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
|
||||
"""Send a command to the browser and return the response data."""
|
||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
if remote_endpoint:
|
||||
remote_endpoint = _normalize_endpoint(remote_endpoint)
|
||||
remote_alias_target = None
|
||||
if not remote_endpoint and requested_profile:
|
||||
remote_alias_target = remote_target_for_alias(requested_profile)
|
||||
@@ -276,40 +393,43 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
remote_endpoint = remote_alias_target.remote
|
||||
requested_profile = remote_alias_target.profile
|
||||
|
||||
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or (remote_alias_target.token if remote_alias_target else None) or token_for_remote(remote_endpoint)
|
||||
msg = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"command": command,
|
||||
"args": args or {},
|
||||
}
|
||||
if remote_endpoint:
|
||||
private_key = _load_private_key(key)
|
||||
# use token auth only when no Ed25519 key is available
|
||||
if private_key is None and resolved_token:
|
||||
msg["token"] = resolved_token
|
||||
msg["user_agent"] = _USER_AGENT
|
||||
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
|
||||
key_spec = key if key is not None else key_for_remote(remote_endpoint)
|
||||
private_key = _load_private_key(key_spec)
|
||||
# persist explicit key spec so future calls don't need --key
|
||||
if key is not None:
|
||||
save_remote_key(remote_endpoint, str(key))
|
||||
route_profile = requested_profile
|
||||
if not route_profile and command != "browser-cli.targets":
|
||||
route_profile = _auto_route_remote(remote_endpoint, resolved_token)
|
||||
_no_route_commands = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
|
||||
if not route_profile and command not in _no_route_commands:
|
||||
route_profile = _auto_route_remote(remote_endpoint, key=key_spec)
|
||||
if route_profile:
|
||||
msg["_route"] = route_profile
|
||||
else:
|
||||
private_key = None
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
|
||||
try:
|
||||
if remote_endpoint:
|
||||
response = _send_remote(remote_endpoint, msg, private_key)
|
||||
elif is_windows():
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock_path = _resolve_socket(profile)
|
||||
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
||||
conn.send_bytes(payload)
|
||||
response = conn.recv_bytes()
|
||||
else:
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock_path = _resolve_socket(profile)
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(sock_path)
|
||||
sock.sendall(framed)
|
||||
sock.sendall(struct.pack("<I", len(payload)) + payload)
|
||||
response = _recv_all(sock)
|
||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||
if remote_endpoint:
|
||||
@@ -338,6 +458,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
def _recv_all(sock: socket.socket) -> bytes:
|
||||
raw_len = _recv_exact(sock, 4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
if msg_len > _MAX_MSG_BYTES:
|
||||
raise ConnectionError(f"Response too large ({msg_len} bytes)")
|
||||
return _recv_exact(sock, msg_len)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
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")
|
||||
def cookies_group():
|
||||
"""Manage browser cookies."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
import json
|
||||
@@ -7,17 +7,6 @@ import json
|
||||
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")
|
||||
def dom_group():
|
||||
"""Query and interact with page DOM elements."""
|
||||
|
||||
@@ -3,7 +3,7 @@ import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
@@ -423,17 +423,6 @@ def _convert_html_to_markdown(html):
|
||||
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")
|
||||
def extract_group():
|
||||
"""Extract content from the active tab."""
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
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.table import Table
|
||||
|
||||
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, token=None):
|
||||
try:
|
||||
if remote:
|
||||
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
|
||||
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")
|
||||
if remote:
|
||||
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||
else:
|
||||
targets = active_browser_targets()
|
||||
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:
|
||||
if not groups:
|
||||
console.print("[yellow]No groups found[/yellow]")
|
||||
@@ -77,7 +43,7 @@ def group_list():
|
||||
if targets:
|
||||
groups = []
|
||||
for target in targets:
|
||||
result = _handle_multi("group.list", profile=target.profile, remote=target.remote, token=target.token)
|
||||
result = _handle_multi("group.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
groups.extend({**group, "browser": target.display_name} for group in result)
|
||||
@@ -110,7 +76,7 @@ def group_count():
|
||||
total = 0
|
||||
rows = 0
|
||||
for target in targets:
|
||||
count = _handle_multi("group.count", profile=target.profile, remote=target.remote, token=target.token)
|
||||
count = _handle_multi("group.count", profile=target.profile, remote=target.remote)
|
||||
if count is None:
|
||||
continue
|
||||
count = int(count or 0)
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import 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")
|
||||
def nav_group():
|
||||
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
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")
|
||||
def page_group():
|
||||
"""Inspect current page metadata."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import click
|
||||
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
|
||||
|
||||
console = Console()
|
||||
@@ -71,14 +71,7 @@ def _build_command(engine_key: str, help_text: str) -> click.Command:
|
||||
def _cmd(query, bg, window, group):
|
||||
terms = " ".join(query)
|
||||
url = ENGINES[engine_key].format(query=quote_plus(terms))
|
||||
try:
|
||||
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)
|
||||
_handle("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
|
||||
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
||||
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
||||
|
||||
+172
-65
@@ -1,20 +1,20 @@
|
||||
import hmac, threading, secrets, socket, struct, click, json, sys
|
||||
import re, threading, secrets, socket, struct, click, json, sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
_CONN_LIMIT = threading.BoundedSemaphore(64)
|
||||
from rich.console import Console
|
||||
from datetime import datetime
|
||||
|
||||
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 _recv_exact(sock:socket.socket, n:int) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
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")
|
||||
@@ -25,24 +25,42 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
|
||||
else:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
|
||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None, nonce:str) -> None:
|
||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None) -> None:
|
||||
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
||||
from browser_cli.platform import is_windows
|
||||
|
||||
try:
|
||||
header = _recv_exact(client_sock, 4)
|
||||
msg_len = struct.unpack("<I", header)[0]
|
||||
payload = _recv_exact(client_sock, msg_len)
|
||||
except (ConnectionError, OSError):
|
||||
return
|
||||
response_secret = None
|
||||
|
||||
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:
|
||||
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
|
||||
try:
|
||||
client_sock.sendall(struct.pack("<I", len(err)) + err)
|
||||
_send_payload(err)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _send_ok(msg_id, payload) -> None:
|
||||
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
|
||||
try:
|
||||
_send_payload(out)
|
||||
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):
|
||||
@@ -50,7 +68,24 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
_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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -65,16 +100,46 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
_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):
|
||||
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
|
||||
elif server_token is not None:
|
||||
if not hmac.compare_digest(msg.get("token") or "", server_token):
|
||||
_send_error(msg_id, "unauthorized: invalid or missing token")
|
||||
_log(addr, command, None, "DENIED", "bad token")
|
||||
return
|
||||
response_secret = pq_shared_secret if transport_encrypted else None
|
||||
|
||||
if command == "browser-cli.targets":
|
||||
from browser_cli.client import active_browser_targets
|
||||
@@ -82,20 +147,47 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
{"profile": target.profile, "displayName": target.display_name}
|
||||
for target in active_browser_targets(include_remotes=False)
|
||||
]
|
||||
data = json.dumps({"id": msg_id, "success": True, "data": targets}).encode()
|
||||
client_sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
_send_ok(msg_id, targets)
|
||||
_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)
|
||||
_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})
|
||||
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||
return
|
||||
|
||||
resolved_profile = msg.get("_route") or profile
|
||||
|
||||
strip = {"token", "_route", "pubkey", "sig"}
|
||||
if strip & msg.keys():
|
||||
clean_payload = json.dumps({k: v for k, v in msg.items() if k not in strip}).encode()
|
||||
clean_header = struct.pack("<I", len(clean_payload))
|
||||
else:
|
||||
clean_payload = payload
|
||||
clean_header = header
|
||||
# ── strip protocol fields, apply request compat shim, forward ─────────────
|
||||
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted"}
|
||||
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)
|
||||
@@ -109,18 +201,18 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
||||
pipe.send_bytes(clean_payload)
|
||||
resp = pipe.recv_bytes()
|
||||
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
|
||||
resp_payload = pipe.recv_bytes()
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
_send_payload(resp_payload)
|
||||
else:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
|
||||
local.connect(sock_path)
|
||||
local.sendall(clean_header + clean_payload)
|
||||
resp_header = _recv_exact(local, 4)
|
||||
resp_len = struct.unpack("<I", resp_header)[0]
|
||||
resp_payload = _recv_exact(local, resp_len)
|
||||
client_sock.sendall(resp_header + resp_payload)
|
||||
resp_payload = _recv_all(local)
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
_send_payload(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):
|
||||
_log(addr, command, resolved_profile, "OK")
|
||||
else:
|
||||
@@ -129,32 +221,51 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
_send_error(msg_id, str(e))
|
||||
_log(addr, command, resolved_profile, "ERROR", str(e))
|
||||
|
||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None) -> None:
|
||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
|
||||
if not _CONN_LIMIT.acquire(blocking=False):
|
||||
client_sock.close()
|
||||
return
|
||||
client_sock.settimeout(30)
|
||||
try:
|
||||
with client_sock:
|
||||
# reload on every connection so auth trust --remote takes effect immediately
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
auth_keys: list[str] | None = load_authorized_keys(auth_keys_path)
|
||||
else:
|
||||
auth_keys = None
|
||||
nonce = secrets.token_hex(32)
|
||||
challenge = json.dumps({"type": "challenge", "nonce": nonce}).encode()
|
||||
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:
|
||||
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
|
||||
_framed_send(client_sock, challenge)
|
||||
except OSError:
|
||||
return
|
||||
_proxy_request(client_sock, addr, profile, server_token, auth_keys, nonce)
|
||||
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key)
|
||||
finally:
|
||||
_CONN_LIMIT.release()
|
||||
|
||||
|
||||
@click.command("serve")
|
||||
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
||||
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
|
||||
@click.option("--token", default=None, metavar="TOKEN", help="Auth token (auto-generated if omitted).")
|
||||
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
|
||||
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Enables pubkey auth.")
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
|
||||
@click.pass_context
|
||||
def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
|
||||
@@ -163,17 +274,14 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
||||
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
path = Path(auth_keys_file)
|
||||
auth_keys = load_authorized_keys(path)
|
||||
if not auth_keys:
|
||||
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {path}")
|
||||
server_token = None
|
||||
auth_keys_path = Path(auth_keys_file)
|
||||
if not load_authorized_keys(auth_keys_path):
|
||||
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
|
||||
elif no_auth:
|
||||
auth_keys = None
|
||||
server_token = None
|
||||
auth_keys_path = None
|
||||
else:
|
||||
auth_keys = None
|
||||
server_token = token or secrets.token_urlsafe(32)
|
||||
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
|
||||
sys.exit(1)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
@@ -185,17 +293,16 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
||||
sys.exit(1)
|
||||
server.listen(16)
|
||||
|
||||
current_ver = get_installed_version()
|
||||
browser_hint = f" (browser: {profile})" if profile else ""
|
||||
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]")
|
||||
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
|
||||
|
||||
if auth_keys is not None:
|
||||
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({len(auth_keys)} trusted key{'s' if len(auth_keys) != 1 else ''})")
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
n = len(load_authorized_keys(auth_keys_path))
|
||||
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
elif server_token:
|
||||
console.print(f" Token: [bold yellow]{server_token}[/bold yellow]")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\", token=\"{server_token}\").tabs_list()[/dim]")
|
||||
else:
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
@@ -206,7 +313,7 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
||||
try:
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token, auth_keys), daemon=True).start()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
|
||||
except KeyboardInterrupt:
|
||||
console.print("[yellow]Stopped.[/yellow]")
|
||||
finally:
|
||||
|
||||
@@ -1,44 +1,10 @@
|
||||
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
|
||||
|
||||
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, token=None):
|
||||
try:
|
||||
if remote:
|
||||
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
|
||||
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")
|
||||
if remote:
|
||||
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||
else:
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
return []
|
||||
return targets
|
||||
|
||||
|
||||
@click.group("session")
|
||||
def session_group():
|
||||
"""Save and restore browser sessions."""
|
||||
@@ -98,7 +64,7 @@ def session_list():
|
||||
if targets:
|
||||
sessions = []
|
||||
for target in targets:
|
||||
result = _handle_multi("session.list", profile=target.profile, remote=target.remote, token=target.token)
|
||||
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
sessions.extend({**session, "browser": target.display_name} for session in result)
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import json
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import 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")
|
||||
def storage_group():
|
||||
"""Read and write the page's localStorage / sessionStorage."""
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
import base64
|
||||
import binascii
|
||||
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.table import Table
|
||||
|
||||
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, token=None):
|
||||
try:
|
||||
if remote:
|
||||
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
|
||||
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")
|
||||
if remote:
|
||||
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||
else:
|
||||
targets = active_browser_targets()
|
||||
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:
|
||||
if not tabs:
|
||||
console.print("[yellow]No tabs found[/yellow]")
|
||||
@@ -83,7 +49,7 @@ def tabs_list():
|
||||
if targets:
|
||||
tabs = []
|
||||
for target in targets:
|
||||
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote, token=target.token)
|
||||
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
tabs.extend({**tab, "browser": target.display_name} for tab in result)
|
||||
@@ -170,7 +136,7 @@ def tabs_count(pattern):
|
||||
total = 0
|
||||
rows = 0
|
||||
for target in targets:
|
||||
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote, token=target.token)
|
||||
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote)
|
||||
if count is None:
|
||||
continue
|
||||
count = int(count or 0)
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
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.table import Table
|
||||
|
||||
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, token=None):
|
||||
try:
|
||||
if remote:
|
||||
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
|
||||
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")
|
||||
if remote:
|
||||
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||
else:
|
||||
targets = active_browser_targets()
|
||||
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:
|
||||
if not windows:
|
||||
console.print("[yellow]No windows found[/yellow]")
|
||||
@@ -75,7 +41,7 @@ def windows_list():
|
||||
if targets:
|
||||
windows = []
|
||||
for target in targets:
|
||||
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote, token=target.token)
|
||||
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
windows.extend({**window, "browser": target.display_name} for window in result)
|
||||
|
||||
@@ -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 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
|
||||
|
||||
SOCKET_PATH: str = "" # set after hello handshake
|
||||
@@ -156,6 +157,7 @@ def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
|
||||
path.unlink()
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(sock_path)
|
||||
os.chmod(sock_path, 0o600)
|
||||
sock.listen(16)
|
||||
|
||||
while True:
|
||||
@@ -277,6 +279,8 @@ def _recv_all(conn: socket.socket) -> bytes | None:
|
||||
if raw_len is None:
|
||||
return None
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
if msg_len > _MAX_MSG_BYTES:
|
||||
return None
|
||||
return _recv_exact(conn, msg_len)
|
||||
|
||||
|
||||
@@ -319,6 +323,7 @@ def main():
|
||||
path.unlink()
|
||||
bound_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
bound_sock.bind(sock_path)
|
||||
os.chmod(sock_path, 0o600)
|
||||
bound_sock.listen(16)
|
||||
else:
|
||||
bound_sock = None
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||
|
||||
|
||||
def parse_version(v: str) -> tuple[int, ...]:
|
||||
try:
|
||||
return tuple(int(x) for x in v.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0,)
|
||||
|
||||
|
||||
def get_installed_version() -> str:
|
||||
try:
|
||||
return _pkg_version("browser-cli")
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.8",
|
||||
"description": "Control your browser from the terminal via browser-cli",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
|
||||
+103
-45
@@ -1,23 +1,64 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
|
||||
import { executeScript, getActiveTab, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl } from '../core';
|
||||
import { contentDispatch } from './injected';
|
||||
export async function domOp(funcName, args) {
|
||||
const tab = await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
||||
|
||||
function fallbackForErrorPageDomOp(funcName, tab) {
|
||||
switch (funcName) {
|
||||
case "domExists":
|
||||
return false;
|
||||
case "domQuery":
|
||||
case "domAttr":
|
||||
case "domText":
|
||||
case "extractLinks":
|
||||
case "extractImages":
|
||||
return [];
|
||||
case "extractText":
|
||||
case "extractMarkdown":
|
||||
return "";
|
||||
case "pageInfo":
|
||||
return {
|
||||
title: tab.title || "",
|
||||
url: tab.url || tab.pendingUrl || "",
|
||||
readyState: "error",
|
||||
lang: null,
|
||||
meta: {},
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function domOp(funcName, args = {}) {
|
||||
const tab = args?.tabId ? await chrome.tabs.get(args.tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
const fallback = fallbackForErrorPageDomOp(funcName, tab);
|
||||
if (fallback !== undefined) return fallback;
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: contentDispatch,
|
||||
args: [funcName, args],
|
||||
});
|
||||
return results[0]?.result;
|
||||
} catch (e) {
|
||||
if (isErrorPageScriptError(e)) {
|
||||
const fallback = fallbackForErrorPageDomOp(funcName, tab);
|
||||
if (fallback !== undefined) return fallback;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: contentDispatch,
|
||||
args: [funcName, args],
|
||||
});
|
||||
return results[0]?.result;
|
||||
}
|
||||
|
||||
export async function domEval({ code, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (!isScriptableUrl(tabUrl) || isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
@@ -30,26 +71,36 @@ export async function domEval({ code, tabId } = {}) {
|
||||
|
||||
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
if (hidden) return { selector, found: false };
|
||||
throw new Error(`Cannot wait for DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, vis, hid) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (hid) return !el || el.offsetParent === null;
|
||||
if (!el) return false;
|
||||
if (vis) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
args: [selector, visible, hidden],
|
||||
});
|
||||
if (results[0]?.result) return { selector, found: !hidden };
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, vis, hid) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (hid) return !el || el.offsetParent === null;
|
||||
if (!el) return false;
|
||||
if (vis) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
args: [selector, visible, hidden],
|
||||
});
|
||||
if (results[0]?.result) return { selector, found: !hidden };
|
||||
} catch (e) {
|
||||
if (hidden && isErrorPageScriptError(e)) return { selector, found: false };
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
|
||||
@@ -57,26 +108,33 @@ export async function domWaitFor({ selector, timeout = 10000, visible = false, h
|
||||
|
||||
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot poll DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
while (Date.now() < deadline) {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, a) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
if (a) return el.getAttribute(a) ?? el[a] ?? null;
|
||||
return el.value !== undefined ? el.value : el.textContent.trim();
|
||||
},
|
||||
args: [selector, attr || null],
|
||||
});
|
||||
const value = results[0]?.result;
|
||||
if (value != null && regex.test(String(value))) return { selector, value, pattern };
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, a) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
if (a) return el.getAttribute(a) ?? el[a] ?? null;
|
||||
return el.value !== undefined ? el.value : el.textContent.trim();
|
||||
},
|
||||
args: [selector, attr || null],
|
||||
});
|
||||
const value = results[0]?.result;
|
||||
if (value != null && regex.test(String(value))) return { selector, value, pattern };
|
||||
} catch (e) {
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { getActiveTab, getAliases, resolveGroupId, tabInfo } from '../core';
|
||||
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
|
||||
export async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
|
||||
let windowId;
|
||||
if (explicitWindowId != null) {
|
||||
@@ -77,6 +77,10 @@ export async function navWait({ tabId, timeout = 30000, readyState = "complete"
|
||||
const interval = 200;
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
const currentUrl = t.url || t.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(currentUrl)) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
|
||||
}
|
||||
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
|
||||
return tabInfo(t);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, getAliases, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core';
|
||||
import { executeScript, getActiveTab, getAliases, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core';
|
||||
export async function tabsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
@@ -102,8 +102,12 @@ export async function tabsQuery({ search }) {
|
||||
export async function tabsHtml({ tabId }) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url || tab.pendingUrl || "")) {
|
||||
throw new Error(`Cannot get HTML of ${tab.url || tab.pendingUrl} — navigate to a regular web page first`);
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
return "";
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot get HTML of ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
try {
|
||||
const results = await executeScript({
|
||||
@@ -112,7 +116,8 @@ export async function tabsHtml({ tabId }) {
|
||||
});
|
||||
return results[0]?.result || "";
|
||||
} catch (e) {
|
||||
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page");
|
||||
if (isErrorPageScriptError(e)) return "";
|
||||
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id"));
|
||||
if (i < 2 && transient) {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
continue;
|
||||
@@ -191,13 +196,26 @@ export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
let lastUrl = tab.url || tab.pendingUrl || "";
|
||||
let lastStatus = tab.status || "unknown";
|
||||
|
||||
const matches = (url) => {
|
||||
regex.lastIndex = 0;
|
||||
return Boolean(url && regex.test(url));
|
||||
};
|
||||
if (matches(lastUrl)) return tabInfo(tab);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
const url = t.url || t.pendingUrl || "";
|
||||
if (regex.test(url)) return tabInfo(t);
|
||||
lastUrl = t.url || t.pendingUrl || "";
|
||||
lastStatus = t.status || "unknown";
|
||||
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
||||
if (isBrowserErrorUrl(t.url || "")) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`);
|
||||
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`);
|
||||
}
|
||||
|
||||
export async function tabsMute({ tabId }) {
|
||||
|
||||
+28
-1
@@ -5,12 +5,39 @@ export async function getProfileAlias() {
|
||||
return profileAlias || "default";
|
||||
}
|
||||
|
||||
export function isBrowserErrorUrl(url) {
|
||||
const value = String(url || "").toLowerCase();
|
||||
return value.startsWith("chrome-error://") ||
|
||||
value.startsWith("edge-error://") ||
|
||||
value.startsWith("brave-error://") ||
|
||||
value.startsWith("about:neterror") ||
|
||||
value.startsWith("about:certerror") ||
|
||||
value.startsWith("about:blocked") ||
|
||||
value.startsWith("about:tabcrashed");
|
||||
}
|
||||
|
||||
export function isErrorPageScriptError(error) {
|
||||
const message = String(error?.message || error || "").toLowerCase();
|
||||
return message.includes("error page") ||
|
||||
message.includes("chrome-error://") ||
|
||||
message.includes("edge-error://") ||
|
||||
message.includes("brave-error://") ||
|
||||
message.includes("about:neterror") ||
|
||||
message.includes("about:certerror") ||
|
||||
message.includes("about:tabcrashed");
|
||||
}
|
||||
|
||||
export function isTransientScriptError(error) {
|
||||
const message = String(error?.message || error || "");
|
||||
return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error);
|
||||
}
|
||||
|
||||
export async function executeScript(options, retries = 3) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await chrome.scripting.executeScript(options);
|
||||
} catch (e) {
|
||||
if (i < retries - 1 && e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page")) {
|
||||
if (i < retries - 1 && isTransientScriptError(e)) {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
continue;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
[project]
|
||||
name = "browser-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.9.8"
|
||||
description = "Control your real running browser from the terminal via a browser extension"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"click>=8",
|
||||
"cryptography>=42",
|
||||
"cryptography>=48",
|
||||
"rich>=13",
|
||||
]
|
||||
|
||||
|
||||
+46
-47
@@ -65,10 +65,9 @@ class TestBrowserCLIInit:
|
||||
assert b._browser == "chrome"
|
||||
|
||||
def test_remote_options_stored(self):
|
||||
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
|
||||
b = BrowserCLI(browser="work", remote="host:8765", key=None)
|
||||
assert b._browser == "work"
|
||||
assert b._remote == "host:8765"
|
||||
assert b._token == "secret"
|
||||
|
||||
|
||||
# ── Internal factories ────────────────────────────────────────────────────────
|
||||
@@ -129,7 +128,7 @@ class TestNavigation:
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.open",
|
||||
{"url": "https://example.com", "background": False, "window": None, "group": None},
|
||||
profile=None, remote=None, token=None, key=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_open_background(self, b, mock_send):
|
||||
@@ -143,38 +142,38 @@ class TestNavigation:
|
||||
|
||||
def test_reload(self, b, mock_send):
|
||||
b.reload(tab_id=5)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_hard_reload(self, b, mock_send):
|
||||
b.hard_reload(tab_id=7)
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_back(self, b, mock_send):
|
||||
b.back(tab_id=3)
|
||||
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_forward(self, b, mock_send):
|
||||
b.forward(tab_id=3)
|
||||
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_focus_url(self, b, mock_send):
|
||||
b.focus_url("github.com")
|
||||
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_navigate_tab(self, b, mock_send):
|
||||
b.navigate_tab(5, "https://example.com")
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
|
||||
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_profile_forwarded(self, b_profile, mock_send):
|
||||
b_profile.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, key=None)
|
||||
|
||||
def test_remote_forwarded(self, mock_send):
|
||||
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
|
||||
b = BrowserCLI(browser="work", remote="host:8765", key=None)
|
||||
b.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret", key=None)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", key=None)
|
||||
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────────────
|
||||
@@ -207,12 +206,12 @@ class TestExtract:
|
||||
result = b.extract_markdown()
|
||||
|
||||
assert result == "# Title"
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_extract_markdown_selector(self, b, mock_send):
|
||||
b.extract_markdown("article")
|
||||
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
|
||||
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
@@ -247,7 +246,7 @@ class TestTabs:
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.close",
|
||||
{"tabId": 10, "inactive": False, "duplicates": False},
|
||||
profile=None, remote=None, token=None, key=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_move(self, b, mock_send):
|
||||
@@ -255,19 +254,19 @@ class TestTabs:
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.move",
|
||||
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
||||
profile=None, remote=None, token=None, key=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_active(self, b, mock_send):
|
||||
b.tabs_active(10)
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_window_active_tab(self, b, mock_send):
|
||||
mock_send.return_value = TAB_DATA
|
||||
tab = b.window_active_tab(1)
|
||||
assert isinstance(tab, Tab)
|
||||
assert tab.id == 10
|
||||
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_window_active_tab_missing_raises(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
@@ -319,17 +318,17 @@ class TestTabs:
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="default"),
|
||||
call("tabs.list", {}, profile="work"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None, key=None),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
b = BrowserCLI(remote="host:8765", token="secret", key=None)
|
||||
b = BrowserCLI(remote="host:8765", key=None)
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||
), patch(
|
||||
"browser_cli.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
):
|
||||
mock_send.side_effect = [[TAB_DATA], None]
|
||||
tabs = b.tabs_list()
|
||||
@@ -337,8 +336,8 @@ class TestTabs:
|
||||
|
||||
assert [tab.browser for tab in tabs] == ["host:work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="host:8765", token="secret", key=None),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret", key=None),
|
||||
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
@@ -381,7 +380,7 @@ class TestTabs:
|
||||
|
||||
def test_tabs_sort(self, b, mock_send):
|
||||
b.tabs_sort(by="title")
|
||||
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs_merge_windows(self, b, mock_send):
|
||||
mock_send.return_value = {"moved": 4}
|
||||
@@ -414,7 +413,7 @@ class TestGroups:
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
tabs = b.group_tabs(42)
|
||||
assert isinstance(tabs[0], Tab)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_group_count(self, b, mock_send):
|
||||
mock_send.return_value = 7
|
||||
@@ -442,17 +441,17 @@ class TestGroups:
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="default"),
|
||||
call("group.list", {}, profile="work"),
|
||||
call("group.close", {"groupId": 99}, profile="work", remote=None, token=None, key=None),
|
||||
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
b = BrowserCLI(remote="host:8765", token="secret", key=None)
|
||||
b = BrowserCLI(remote="host:8765", key=None)
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||
), patch(
|
||||
"browser_cli.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
):
|
||||
mock_send.side_effect = [[GROUP_DATA], None]
|
||||
groups = b.group_list()
|
||||
@@ -460,8 +459,8 @@ class TestGroups:
|
||||
|
||||
assert [group.browser for group in groups] == ["host:work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="host:8765", token="secret", key=None),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret", key=None),
|
||||
call("group.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
@@ -484,7 +483,7 @@ class TestGroups:
|
||||
|
||||
def test_group_close(self, b, mock_send):
|
||||
b.group_close(42)
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_group_create_dict_response(self, b, mock_send):
|
||||
mock_send.return_value = GROUP_DATA
|
||||
@@ -504,7 +503,7 @@ class TestGroups:
|
||||
tab_id = b.group_add_tab(42, "https://example.com")
|
||||
assert tab_id == 55
|
||||
mock_send.assert_called_once_with(
|
||||
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
|
||||
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_group_add_tab_non_dict_response(self, b, mock_send):
|
||||
@@ -514,7 +513,7 @@ class TestGroups:
|
||||
def test_group_move_forward(self, b, mock_send):
|
||||
b.group_move(42, forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
|
||||
@@ -544,7 +543,7 @@ class TestWindows:
|
||||
result = b.windows_open()
|
||||
|
||||
assert result == {"id": 5}
|
||||
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_windows_open_with_url(self, b, mock_send):
|
||||
mock_send.return_value = {"id": 9}
|
||||
@@ -552,7 +551,7 @@ class TestWindows:
|
||||
result = b.windows_open("https://example.com")
|
||||
|
||||
assert result == {"id": 9}
|
||||
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
|
||||
class TestSession:
|
||||
@@ -562,7 +561,7 @@ class TestSession:
|
||||
result = b.session_list()
|
||||
|
||||
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
||||
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
|
||||
with patch(
|
||||
@@ -597,26 +596,26 @@ class TestTabModel:
|
||||
|
||||
def test_close(self, tab, mock_send):
|
||||
tab.close()
|
||||
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_activate(self, tab, mock_send):
|
||||
tab.activate()
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_reload(self, tab, mock_send):
|
||||
tab.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_hard_reload(self, tab, mock_send):
|
||||
tab.hard_reload()
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_move_forward(self, tab, mock_send):
|
||||
tab.move(forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.move",
|
||||
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
||||
profile=None, remote=None, token=None, key=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_move_to_group(self, tab, mock_send):
|
||||
@@ -626,12 +625,12 @@ class TestTabModel:
|
||||
def test_html(self, tab, mock_send):
|
||||
mock_send.return_value = "<html/>"
|
||||
assert tab.html() == "<html/>"
|
||||
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_open(self, tab, mock_send):
|
||||
tab.open("https://new.example.com")
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None, key=None
|
||||
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_open_background_changes_same_tab(self, tab, mock_send):
|
||||
@@ -639,7 +638,7 @@ class TestTabModel:
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.to",
|
||||
{"tabId": 10, "url": "https://new.example.com"},
|
||||
profile=None, remote=None, token=None, key=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_unbound_raises(self):
|
||||
@@ -657,18 +656,18 @@ class TestGroupModel:
|
||||
|
||||
def test_close(self, group, mock_send):
|
||||
group.close()
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs(self, group, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
tabs = group.tabs()
|
||||
assert isinstance(tabs[0], Tab)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_move_forward(self, group, mock_send):
|
||||
group.move(forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_move_backward(self, group, mock_send):
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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,
|
||||
pq_decrypt,
|
||||
pq_encrypt,
|
||||
pq_kex_client_encapsulate,
|
||||
pq_kex_server_decapsulate,
|
||||
pq_kex_server_keypair,
|
||||
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_auth_protocol_fields(self):
|
||||
msg = {"command": "tabs.list", "id": "x", "pubkey": "abc", "sig": "def", "pq_kex": {"alg": "ML-KEM-768"}}
|
||||
data = json.loads(canonical_payload(msg))
|
||||
assert "pubkey" not in data
|
||||
assert "sig" not in data
|
||||
assert "pq_kex" 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_post_quantum_shared_secret_is_bound_to_signature(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list", "pq_kex": {"alg": "ML-KEM-768", "ciphertext": "abcd"}}
|
||||
sig = sign(priv, nonce, msg, b"shared-secret").hex()
|
||||
assert verify(pub_hex, nonce, msg, sig, b"shared-secret") is True
|
||||
assert verify(pub_hex, nonce, msg, sig, b"other-secret") is False
|
||||
assert verify(pub_hex, 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 TestPostQuantumKex:
|
||||
def test_mlkem_roundtrip_when_backend_supports_it(self):
|
||||
keypair = pq_kex_server_keypair()
|
||||
if keypair is None:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
priv, pub = keypair
|
||||
|
||||
ciphertext_hex, client_secret = pq_kex_client_encapsulate(pub.hex())
|
||||
server_secret = pq_kex_server_decapsulate(priv, ciphertext_hex)
|
||||
|
||||
assert server_secret == client_secret
|
||||
assert len(server_secret) == 32
|
||||
|
||||
def test_pq_transport_encrypt_decrypt_roundtrip(self):
|
||||
secret = b"s" * 32
|
||||
plaintext = b'{"command":"tabs.list"}'
|
||||
|
||||
envelope = pq_encrypt(secret, "request", plaintext)
|
||||
|
||||
assert envelope["alg"] == "ML-KEM-768+ChaCha20Poly1305"
|
||||
assert plaintext.hex() not in envelope["ciphertext"]
|
||||
assert pq_decrypt(secret, "request", envelope) == plaintext
|
||||
|
||||
def test_pq_transport_direction_is_bound(self):
|
||||
secret = b"s" * 32
|
||||
envelope = pq_encrypt(secret, "request", b"payload")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
pq_decrypt(secret, "response", envelope)
|
||||
|
||||
|
||||
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
|
||||
+38
-52
@@ -168,19 +168,16 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
||||
assert "0.8.2" in result.output
|
||||
|
||||
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "clients.list"
|
||||
assert profile is None
|
||||
assert remote == "127.0.0.1:8765"
|
||||
assert token == "test"
|
||||
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
|
||||
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command, patch(
|
||||
"browser_cli.cli.save_remote_token"
|
||||
):
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--token", "test", "clients"])
|
||||
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once()
|
||||
@@ -194,7 +191,7 @@ def test_clients_remote_respects_global_browser_route():
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", token=None, key=None)
|
||||
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
|
||||
|
||||
|
||||
def test_clients_browser_alias_resolves_to_remote():
|
||||
@@ -207,15 +204,13 @@ def test_clients_browser_alias_resolves_to_remote():
|
||||
display_name="192.168.188.104:automatisation",
|
||||
socket_path="",
|
||||
remote="192.168.188.104:8765",
|
||||
token="tok",
|
||||
)
|
||||
all_remote_targets = [resolved_target]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "clients.list"
|
||||
assert profile == "automatisation"
|
||||
assert remote == "192.168.188.104:8765"
|
||||
assert token == "tok"
|
||||
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
@@ -267,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"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -283,32 +278,32 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
||||
|
||||
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
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"),
|
||||
), patch(
|
||||
"browser_cli.commands.tabs.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765", token="secret")],
|
||||
"browser_cli.commands.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
|
||||
), 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"}],
|
||||
) as send_command, patch("browser_cli.cli.save_remote_token"):
|
||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "--token", "secret", "tabs", "list"])
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "remote-host:work" in result.output
|
||||
assert "Remote" in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", token="secret")
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765")
|
||||
|
||||
|
||||
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), 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"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
|
||||
@@ -327,12 +322,12 @@ def test_tabs_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -349,12 +344,12 @@ def test_group_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.groups.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -365,7 +360,7 @@ def test_group_count_multi_browser_shows_total():
|
||||
|
||||
def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
with patch(
|
||||
"browser_cli.commands.groups.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
|
||||
):
|
||||
result = CliRunner().invoke(main, ["groups", "list"])
|
||||
@@ -377,7 +372,7 @@ def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -389,7 +384,7 @@ def test_tabs_move_accepts_right_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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -404,12 +399,12 @@ def test_windows_list_multi_browser_shows_browser_column():
|
||||
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.windows.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -425,12 +420,12 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.session.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -443,13 +438,13 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
|
||||
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.session.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch(
|
||||
"browser_cli.commands.session.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
|
||||
@@ -460,7 +455,7 @@ def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -468,20 +463,20 @@ def test_windows_open_passes_url():
|
||||
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
||||
|
||||
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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
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():
|
||||
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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
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():
|
||||
@@ -566,7 +561,7 @@ def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
|
||||
"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"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -634,14 +629,6 @@ def test_convert_html_to_markdown_indents_multiline_list_items():
|
||||
" Local LLMs / API Modelle / Spezialmodelle"
|
||||
) in markdown
|
||||
|
||||
def test_remote_token_is_saved_when_passed_on_cli():
|
||||
endpoint = "browser-host.example:8765"
|
||||
with patch("browser_cli.cli.save_remote_token") as save_remote_token:
|
||||
result = CliRunner().invoke(main, ["--remote", endpoint, "--token", "secret", "completion", "bash", "--script"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
save_remote_token.assert_called_once_with(endpoint, "secret")
|
||||
|
||||
|
||||
def test_tabs_list_multi_browser_queries_remote_target():
|
||||
endpoint = "browser-host.example:8765"
|
||||
@@ -650,15 +637,14 @@ def test_tabs_list_multi_browser_queries_remote_target():
|
||||
"browser-host.example:work",
|
||||
"",
|
||||
remote=endpoint,
|
||||
token="secret",
|
||||
)
|
||||
|
||||
with patch("browser_cli.commands.tabs.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||
"browser_cli.commands.tabs.send_command",
|
||||
with patch("browser_cli.commands.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint, token="secret")
|
||||
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint)
|
||||
assert "browser-host.example:work" in result.output
|
||||
|
||||
+177
-37
@@ -6,13 +6,15 @@ import pytest
|
||||
from browser_cli.client import (
|
||||
BrowserNotConnected,
|
||||
BrowserTarget,
|
||||
_looks_like_domain,
|
||||
_normalize_endpoint,
|
||||
_resolve_connect_endpoint,
|
||||
_resolve_socket,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
save_remote_token,
|
||||
key_for_remote,
|
||||
send_command,
|
||||
remote_target_for_alias,
|
||||
token_for_remote,
|
||||
)
|
||||
from browser_cli.platform import endpoint_for_alias
|
||||
|
||||
@@ -93,29 +95,15 @@ def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_
|
||||
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
|
||||
|
||||
|
||||
def test_save_remote_token_persists_per_endpoint(monkeypatch, tmp_path):
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
endpoint = "browser-host.example:8765"
|
||||
|
||||
save_remote_token(endpoint, "secret-token")
|
||||
|
||||
assert token_for_remote(endpoint) == "secret-token"
|
||||
assert json.loads(remotes_path.read_text(encoding="utf-8")) == {
|
||||
endpoint: {"token": "secret-token"}
|
||||
}
|
||||
|
||||
|
||||
def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, token=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
|
||||
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
@@ -124,20 +112,19 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
assert send_command("tabs.list", remote="host:8765", token="secret", key=None) == "ok"
|
||||
assert send_command("tabs.list", remote="host:8765", key=None) == "ok"
|
||||
assert sent["_route"] == "work"
|
||||
assert sent["token"] == "secret"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
@@ -150,13 +137,13 @@ def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
||||
assert send_command("tabs.list") == []
|
||||
assert sent["endpoint"] == "host:8765"
|
||||
assert sent["_route"] == "work"
|
||||
assert sent["token"] == "secret"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
target = remote_target_for_alias("host:8765:work")
|
||||
@@ -171,7 +158,7 @@ def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkey
|
||||
remote_endpoint = f"{remote_host}:8765"
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
target = remote_target_for_alias(remote_host)
|
||||
@@ -185,13 +172,12 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
remote_host = "browser-host.example"
|
||||
remote_endpoint = f"{remote_host}:8765"
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", remote_host)
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
@@ -204,15 +190,15 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
assert send_command("tabs.list") == []
|
||||
assert sent["endpoint"] == remote_endpoint
|
||||
assert sent["_route"] == "work"
|
||||
assert sent["token"] == "secret"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765", token="secret"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765", token="secret"),
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -223,27 +209,26 @@ def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, token=None: [
|
||||
BrowserTarget("main", "host:main", "", remote=endpoint, token=token),
|
||||
BrowserTarget("furry", "host:furry", "", remote=endpoint, token=token),
|
||||
lambda endpoint, key=None: [
|
||||
BrowserTarget("main", "host:main", "", remote=endpoint),
|
||||
BrowserTarget("furry", "host:furry", "", remote=endpoint),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
|
||||
send_command("tabs.list", remote="host:8765", token="secret")
|
||||
send_command("tabs.list", remote="host:8765")
|
||||
|
||||
|
||||
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browser-host.example:8765"
|
||||
remotes_path.write_text(json.dumps({endpoint: {"token": "secret-token"}}), encoding="utf-8")
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "browser-cli.targets"
|
||||
assert remote == endpoint
|
||||
assert token == "secret-token"
|
||||
return [{"profile": "work", "displayName": "work"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
@@ -254,4 +239,159 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
assert targets[0].profile == "work"
|
||||
assert targets[0].display_name == "browser-host.example:work"
|
||||
assert targets[0].remote == endpoint
|
||||
assert targets[0].token == "secret-token"
|
||||
|
||||
|
||||
def test_looks_like_domain():
|
||||
assert _looks_like_domain("browsercli.yiprawr.dev") is True
|
||||
assert _looks_like_domain("browser-host.example") is True
|
||||
assert _looks_like_domain("sub.domain.org") is True
|
||||
assert _looks_like_domain("localhost") is False
|
||||
assert _looks_like_domain("127.0.0.1") is False
|
||||
assert _looks_like_domain("192.168.1.100") is False
|
||||
assert _looks_like_domain("host") is False # no dot
|
||||
|
||||
|
||||
def test_normalize_endpoint_strips_443_for_domains():
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev"
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev"
|
||||
assert _normalize_endpoint("192.168.1.1:443") == "192.168.1.1:443" # IP: keep port
|
||||
assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port
|
||||
assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
|
||||
|
||||
|
||||
def test_resolve_connect_endpoint_adds_443_for_domain():
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443"
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443"
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
|
||||
assert _resolve_connect_endpoint("host:8765") == "host:8765"
|
||||
|
||||
|
||||
def test_resolve_connect_endpoint_raises_for_bare_non_domain():
|
||||
with pytest.raises(BrowserNotConnected, match="expected host:port"):
|
||||
_resolve_connect_endpoint("localhost")
|
||||
|
||||
|
||||
def test_send_command_normalizes_domain_port_443(monkeypatch):
|
||||
"""--remote domain:443 is normalized; _send_remote gets the portless domain."""
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
sent_to = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
sent_to["endpoint"] = endpoint
|
||||
return json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
result = send_command("tabs.list", remote="browsercli.yiprawr.dev:443")
|
||||
assert result == "ok"
|
||||
assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # stored/routed without port
|
||||
|
||||
|
||||
def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
||||
"""--remote domain (no port) is treated as :443."""
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
sent_to = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
sent_to["endpoint"] = endpoint
|
||||
return json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
result = send_command("tabs.list", remote="browsercli.yiprawr.dev")
|
||||
assert result == "ok"
|
||||
assert sent_to["endpoint"] == "browsercli.yiprawr.dev"
|
||||
|
||||
|
||||
def test_domain_display_name_omits_port(monkeypatch, tmp_path):
|
||||
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browsercli.yiprawr.dev"
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
return [{"profile": "automatisation", "displayName": "automatisation"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||
assert targets[0].remote == endpoint
|
||||
|
||||
|
||||
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
|
||||
"""Old remotes.json with :443 still displays cleanly without the port."""
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browsercli.yiprawr.dev:443" # old format
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
return [{"profile": "automatisation", "displayName": "automatisation"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||
|
||||
|
||||
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
|
||||
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
|
||||
import json as _json
|
||||
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
remotes_path.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
||||
|
||||
from pathlib import Path as _Path
|
||||
used_keys = []
|
||||
|
||||
def fake_load_private_key(key_path=None):
|
||||
used_keys.append(str(key_path) if key_path is not None else None)
|
||||
return None # no actual key needed for this test
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._load_private_key", fake_load_private_key)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
return _json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
# First call with explicit --key agent
|
||||
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
|
||||
assert used_keys[-1] == "agent"
|
||||
|
||||
# Key must be persisted now
|
||||
assert key_for_remote("host:8765") == "agent"
|
||||
|
||||
# Second call without --key — should reuse saved "agent"
|
||||
send_command("tabs.list", remote="host:8765")
|
||||
assert used_keys[-1] == "agent"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def test_extension_retries_error_page_script_injection_before_failing():
|
||||
core = (ROOT / "extension" / "src" / "core.ts").read_text()
|
||||
|
||||
assert "isBrowserErrorUrl" in core
|
||||
assert "isErrorPageScriptError" in core
|
||||
assert "chrome-error://" in core
|
||||
assert "edge-error://" in core
|
||||
assert "brave-error://" in core
|
||||
assert "about:neterror" in core
|
||||
assert "about:certerror" in core
|
||||
assert "isTransientScriptError(e)" in core
|
||||
|
||||
def test_read_only_dom_commands_have_error_page_fallbacks():
|
||||
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text()
|
||||
|
||||
assert "fallbackForErrorPageDomOp" in dom
|
||||
assert 'case "domExists":' in dom
|
||||
assert "return false;" in dom
|
||||
assert 'case "domQuery":' in dom
|
||||
assert 'case "extractText":' in dom
|
||||
assert "isBrowserErrorUrl(tabUrl)" in dom
|
||||
assert "isErrorPageScriptError(e)" in dom
|
||||
|
||||
def test_navigation_and_tabs_report_browser_error_pages():
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
|
||||
|
||||
assert "lastUrl" in tabs
|
||||
assert "lastStatus" in tabs
|
||||
assert "showing an error page" in tabs
|
||||
assert "last URL:" in tabs
|
||||
assert "isBrowserErrorUrl" in navigation
|
||||
assert "showing an error page while waiting for load" in navigation
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Remote protocol compatibility matrix for post-quantum transport.
|
||||
|
||||
These tests exercise the wire-level combinations that matter for mixed
|
||||
browser-cli versions without requiring a real browser. The native-host lookup is
|
||||
mocked so successful auth/transport reaches the proxy layer and then returns the
|
||||
expected "browser not connected" error.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth import (
|
||||
generate_keypair,
|
||||
load_private_key,
|
||||
pq_decrypt,
|
||||
pq_encrypt,
|
||||
pq_kex_client_encapsulate,
|
||||
pq_kex_server_decapsulate,
|
||||
pq_kex_server_keypair,
|
||||
sign,
|
||||
)
|
||||
from browser_cli.client import BrowserNotConnected, send_command
|
||||
from browser_cli.commands.serve import _handle_client
|
||||
|
||||
def _send_framed(sock: socket.socket, msg: dict) -> None:
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock.sendall(struct.pack("<I", len(payload)) + payload)
|
||||
|
||||
def _recv_framed(sock: socket.socket) -> dict:
|
||||
raw_len = b""
|
||||
while len(raw_len) < 4:
|
||||
chunk = sock.recv(4 - len(raw_len))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw_len += chunk
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
data = b""
|
||||
while len(data) < msg_len:
|
||||
chunk = sock.recv(msg_len - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_material(tmp_path):
|
||||
pem, pub = generate_keypair()
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
auth_path = tmp_path / "authorized_keys"
|
||||
auth_path.write_text(pub + "\n", encoding="utf-8")
|
||||
return key_path, auth_path, load_private_key(key_path), pub
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_browser(monkeypatch):
|
||||
def _raise_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _raise_no_browser)
|
||||
|
||||
def _connect(auth_keys_path):
|
||||
client, server = socket.socketpair()
|
||||
thread = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
challenge = _recv_framed(client)
|
||||
return client, thread, challenge
|
||||
|
||||
def _pq_auth_message(priv, pub: str, nonce_hex: str, command_msg: dict, challenge: dict, *, encrypted: bool) -> tuple[dict, bytes]:
|
||||
if "pq_kex" not in challenge:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
|
||||
ciphertext_hex, shared_secret = pq_kex_client_encapsulate(challenge["pq_kex"]["public_key"])
|
||||
clean_msg = {
|
||||
**command_msg,
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": ciphertext_hex},
|
||||
}
|
||||
sig = sign(priv, bytes.fromhex(nonce_hex), clean_msg, shared_secret).hex()
|
||||
if not encrypted:
|
||||
return {**clean_msg, "pubkey": pub, "sig": sig}, shared_secret
|
||||
|
||||
envelope = {
|
||||
"id": clean_msg["id"],
|
||||
"user_agent": clean_msg["user_agent"],
|
||||
"pubkey": pub,
|
||||
"sig": sig,
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": pq_encrypt(shared_secret, "request", json.dumps(clean_msg).encode("utf-8")),
|
||||
}
|
||||
return envelope, shared_secret
|
||||
|
||||
def _assert_browser_not_connected(resp: dict) -> None:
|
||||
assert resp.get("success") is False
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
|
||||
def test_real_mlkem_primitive_roundtrip():
|
||||
keypair = pq_kex_server_keypair()
|
||||
if keypair is None:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
private_key, public_key = keypair
|
||||
|
||||
ciphertext_hex, client_secret = pq_kex_client_encapsulate(public_key.hex())
|
||||
server_secret = pq_kex_server_decapsulate(private_key, ciphertext_hex)
|
||||
|
||||
assert server_secret == client_secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("client_version", "encrypted", "expect_encrypted_response"),
|
||||
[
|
||||
("0.9.3", False, False), # legacy client stays compatible
|
||||
("0.9.5", True, True), # current client must use encrypted transport
|
||||
],
|
||||
)
|
||||
def test_remote_protocol_version_matrix(auth_material, client_version, encrypted, expect_encrypted_response):
|
||||
selected_version = os.environ.get("BROWSER_CLI_COMPAT_CLIENT_VERSION")
|
||||
if selected_version and selected_version != client_version:
|
||||
pytest.skip(f"compat matrix selected {selected_version}")
|
||||
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
|
||||
msg = {
|
||||
"id": f"tabs-{client_version}",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": f"browser-cli/{client_version}",
|
||||
}
|
||||
wire_msg, shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=encrypted)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
|
||||
if expect_encrypted_response:
|
||||
assert set(resp) == {"encrypted"}
|
||||
resp = json.loads(pq_decrypt(shared_secret, "response", resp["encrypted"]))
|
||||
else:
|
||||
assert "encrypted" not in resp
|
||||
|
||||
_assert_browser_not_connected(resp)
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_current_client_plaintext_transport_is_rejected(auth_material):
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
|
||||
msg = {
|
||||
"id": "new-plain",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
}
|
||||
wire_msg, _shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=False)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp.get("success") is False
|
||||
assert "encrypted transport" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_send_command_uses_encrypted_remote_transport(auth_material):
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch):
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: None)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
|
||||
assert "not using a post-quantum key exchange" in stderr.getvalue()
|
||||
thread.join(timeout=2)
|
||||
@@ -0,0 +1,373 @@
|
||||
"""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, pq_decrypt, pq_encrypt, 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)
|
||||
|
||||
def test_challenge_advertises_post_quantum_kex_when_available(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
challenge = _recv_framed(client)
|
||||
|
||||
assert challenge["pq_kex"] == {"alg": "ML-KEM-768", "public_key": b"fake-public".hex()}
|
||||
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_missing_post_quantum_kex_rejected_when_required(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
priv_path = tmp_path / "client.pem"
|
||||
priv_path.write_bytes(pem)
|
||||
priv = load_private_key(priv_path)
|
||||
|
||||
client, t, challenge = self._connect(path)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5", "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 "post-quantum" 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_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
|
||||
}
|
||||
msg["sig"] = sign(priv, nonce, msg, b"pq-secret").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_post_quantum_encrypted_transport_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""New clients encrypt the command payload and receive encrypted responses."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
||||
|
||||
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)
|
||||
|
||||
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"])
|
||||
clean_msg = {
|
||||
"id": "x",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
|
||||
}
|
||||
sig = sign(priv, nonce, clean_msg, b"pq-secret").hex()
|
||||
envelope = {
|
||||
"id": "x",
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"pubkey": pub,
|
||||
"sig": sig,
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": pq_encrypt(b"pq-secret", "request", json.dumps(clean_msg).encode()),
|
||||
}
|
||||
_send_framed(client, json.dumps(envelope).encode())
|
||||
encrypted_resp = _recv_framed(client)
|
||||
|
||||
assert "encrypted" in encrypted_resp
|
||||
resp = json.loads(pq_decrypt(b"pq-secret", "response", encrypted_resp["encrypted"]))
|
||||
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]]
|
||||
name = "browser-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.9.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -20,7 +20,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8" },
|
||||
{ name = "cryptography", specifier = ">=42" },
|
||||
{ name = "cryptography", specifier = ">=48" },
|
||||
{ name = "rich", specifier = ">=13" },
|
||||
]
|
||||
|
||||
@@ -132,62 +132,62 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "47.0.0"
|
||||
version = "48.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user