Compare commits

...
8 Commits
Author SHA1 Message Date
daniel156161 1b32410575 Isolate the remote registry in tests
Testing / remote-protocol-compat (0.16.0) (push) Successful in 40s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 42s
Testing / test (push) Successful in 49s
Endpoint resolution consults remembered remotes, so tests that assert the
bare-domain :443 default failed on any machine with an explicit-port remote
remembered for the same host, while passing in CI. That made a real behavior
difference look like a local-only flake.

Isolate the registry for the whole suite instead of patching the two affected
tests, since any test touching endpoint resolution has the same hidden
dependency. Tests that need remembered entries still override the path.

Also replace the hardcoded personal remote host with a documentation domain.
2026-08-09 20:45:56 +02:00
daniel156161 581cd73cac Default MCP tab tools to the active tab
Testing / remote-protocol-compat (0.15.0) (push) Successful in 30s
Testing / test (push) Successful in 40s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 24s
Requiring an explicit tab_id forced every navigate or close through a
preceding tabs_list call, which costs an MCP client a full round trip just to
learn the ID the browser already considers current.

navigate and tabs_close now resolve the active tab when tab_id is omitted,
matching the screenshot tool. Resolution is explicit rather than forwarding
None into the SDK, so the acting tool knows which tab it touched; tabs_close
reports it, since closing the wrong tab is not recoverable.

This stays in the MCP layer: the SDK and CLI signatures are unchanged.
2026-08-09 20:41:41 +02:00
daniel156161 bd2a18baba Add optional stateless MCP server for the real browser
Expose a conservative browser-cli tool surface to MCP hosts without
duplicating browser-control logic: every tool is a thin adapter over the
existing Python SDK, and each call builds a fresh BrowserCLI so the real
browser stays the only owner of tab and navigation state.

The MCP process resolves BROWSER_CLI_PROFILE, BROWSER_CLI_REMOTE, and
BROWSER_CLI_KEY explicitly instead of passing None down, so a pinned server
targets one browser rather than fanning listing calls out across every
connected browser. Explicit tool arguments still win.

Tool names keep a browser_ prefix for hosts that expose raw MCP names, while
BROWSER_CLI_MCP_TOOL_PREFIX lets hosts that already namespace by server drop
it and avoid names like browser_cli_testing_browser_tabs_list.

JavaScript evaluation, raw commands, storage writes, and session import stay
unexposed, and Streamable HTTP refuses non-loopback binds because the endpoint
has no authentication of its own; remote browsers go through browser-cli's
authenticated remote transport instead.

MCP stays an optional extra, so normal installs are unaffected.
2026-08-09 20:37:47 +02:00
daniel156161 7b4d96845d fix(remote): resolve remembered explicit-port endpoints
Testing / remote-protocol-compat (0.16.0) (push) Successful in 56s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m7s
Testing / test (push) Successful in 1m27s
- Resolve bare remote hosts through the remote registry when one explicit port is stored.
- Preserve bare host behavior when no unique explicit-port registry match exists.
- Route requested remote targets through the new registry resolver.
- Add registry tests for unique and ambiguous explicit-port matches.
- Bump package and extension versions to 0.16.6.
2026-07-07 00:38:06 +02:00
daniel156161 937c6a1ce0 fix(clients): flatten scoped remote output
Testing / remote-protocol-compat (0.15.0) (push) Successful in 49s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 52s
Testing / test (push) Successful in 49s
- Render explicit --remote clients results with profile-only labels.

- Suppress remote host group headers for scoped client queries.

- Keep global and mixed client listings grouped by host.

- Update client and CLI tests for scoped remote rendering.

- Bump browser-cli and extension versions to 0.16.5.
2026-06-26 09:12:44 +02:00
daniel156161 6270d8c956 feat: add remote trust and server identity pinning
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
- Add SSH-style server identity keys and known-host verification for remote serve endpoints.
- Add remote add/list/remove commands for explicit endpoint persistence.
- Fix remote clients listing to fan out through target discovery instead of ambiguous auto-routing.
- Add URL glob matching for tabs filter and count with extension tests.
- Add n8n credential pinning for server public keys or SHA256 fingerprints.
- Remove obsolete compat shim behavior while keeping empty compat seams for future protocol changes.
- Bump browser-cli to 0.16.4 and n8n node to 0.3.1.
- Cover known-hosts, remote registry, compat seams, n8n protocol verification, and URL matching with tests.
2026-06-26 08:53:21 +02:00
daniel156161 1ae9c33f00 ci: test current browser-cli client versions
Testing / remote-protocol-compat (0.15.0) (push) Successful in 55s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 54s
Testing / test (push) Successful in 1m2s
- Update the compatibility matrix from legacy 0.9.x clients to the supported 0.15.0 and 0.16.0 client releases.

- Keep the Gitea workflow focused on versions that match the current n8n node protocol expectations.
2026-06-19 12:01:50 +02:00
daniel156161 b91b29d516 feat(n8n): expand browser-cli node operations
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 40s
- Add UI resources and mappings for groups, windows, sessions, storage, performance, extension, and more tab/DOM/page actions.

- Remove the synthetic Gateway Health operation so exposed operations match real browser-cli commands.

- Document the expanded command/policy matrix and cover the new request mappings with tests.

- Cap the node SVG icon at 60x60, bump the n8n package to 0.3.0, and advertise client protocol version 0.16.0.
2026-06-19 11:55:36 +02:00
41 changed files with 3362 additions and 451 deletions
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
fail-fast: false
matrix:
browser-cli-client-version:
- "0.9.3"
- "0.9.5"
- "0.15.0"
- "0.16.0"
steps:
- name: Checkout
+95 -2
View File
@@ -70,6 +70,11 @@ For better remote-response compression, install the optional `fast` extra:
uv tool install "real-browser-cli[fast]"
```
To expose the conservative MCP tool surface, install the optional `mcp` extra:
```sh
uv tool install "real-browser-cli[mcp]"
```
To upgrade later:
```sh
@@ -141,6 +146,89 @@ browser-cli/
---
## Stateless MCP server
The optional MCP adapter exposes a small, typed subset of the Python SDK for
MCP hosts such as Claude Desktop, Claude Code, Cursor, or VS Code. It controls
the same real browser; it does not launch a headless browser or duplicate the
browser command implementation.
Install and run the local stdio server:
```sh
uv tool install "real-browser-cli[mcp]"
browser-cli-mcp
```
Example MCP host configuration:
```json
{
"mcpServers": {
"browser-cli": {
"command": "browser-cli-mcp"
}
}
}
```
The server is stateless at the MCP layer. Every tool call creates a fresh
`BrowserCLI` SDK client, and browser state remains in the real browser. Pass
`browser`, `remote`, and `key` on a tool call when a specific local profile or
authenticated browser-cli remote is required.
`browser_navigate`, `browser_tabs_close`, and `browser_screenshot` take an
optional `tab_id` and act on the active tab when it is omitted, so a caller
does not need a preceding `browser_tabs_list` round trip. `browser_tabs_close`
reports the tab it closed.
Available tools:
- `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close`
- `browser_navigate`, `browser_page_info`
- `browser_extract_text`, `browser_extract_markdown`
- `browser_dom_query`, `browser_dom_click`, `browser_dom_type`
- `browser_screenshot`
Generic JavaScript evaluation, raw browser commands, storage writes, and
session import are intentionally not exposed.
### Pinning one browser and naming tools
Set `BROWSER_CLI_PROFILE` to pin every call from an MCP server to one browser,
so tools do not have to pass `browser` and listing tools do not fan out across
all connected browsers:
```json
{
"mcpServers": {
"browser-cli-testing": {
"command": "browser-cli-mcp",
"env": {
"BROWSER_CLI_PROFILE": "testing",
"BROWSER_CLI_MCP_TOOL_PREFIX": ""
}
}
}
}
```
`BROWSER_CLI_REMOTE` and `BROWSER_CLI_KEY` pin an authenticated remote the same
way. Explicit `browser`, `remote`, and `key` tool arguments still win.
Tool names carry a `browser_` prefix by default so they stay unambiguous in
hosts that expose raw MCP tool names. Hosts that already prefix tools with the
server name produce stutter such as `browser_cli_testing_browser_tabs_list`;
setting `BROWSER_CLI_MCP_TOOL_PREFIX` to an empty string drops the built-in
prefix and yields `browser_cli_testing_tabs_list`. Any other value replaces the
prefix.
For local development and testing, Streamable HTTP is also available:
```sh
browser-cli-mcp --transport streamable-http --port 8000
# endpoint: http://127.0.0.1:8000/mcp
```
HTTP uses stateless JSON responses and intentionally refuses non-loopback bind
addresses because this MCP endpoint has no independent authentication. For a
browser on another machine, keep MCP local and pass an authenticated
browser-cli `remote` target to each tool call.
---
## CLI reference
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
@@ -196,8 +284,9 @@ browser-cli search so click choices
```sh
browser-cli tabs list # list all open tabs (all windows)
browser-cli tabs count # count all tabs
browser-cli tabs count youtube # count tabs matching URL pattern
browser-cli tabs filter youtube # list tabs matching URL pattern
browser-cli tabs count youtube # count tabs whose URL contains "youtube"
browser-cli tabs filter youtube # list tabs whose URL contains "youtube"
browser-cli tabs filter 'twitch.tv/*' # glob: list every twitch.tv tab
browser-cli tabs query "pull request" # search tabs by URL or title
browser-cli tabs active 1234 # switch browser focus to tab
@@ -219,6 +308,10 @@ browser-cli tabs sort --by time
browser-cli tabs merge-windows # pull all tabs into the current window
```
> URL patterns for `tabs filter` / `tabs count` match against the full tab URL.
> A plain string is a case-sensitive substring (`youtube`); a pattern containing
> `*` or `?` is treated as a glob (`twitch.tv/*`, `*.twitch.tv`).
### Tab groups
```sh
browser-cli groups list # list all tab groups
+57
View File
@@ -0,0 +1,57 @@
"""Persistent server identity keys for SSH-style remote host pinning."""
from __future__ import annotations
import json
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key
from browser_cli.constants import CONFIG_DIR
SERVER_IDENTITY_PATH = CONFIG_DIR / "server_identity.pem"
def load_or_create_server_identity(path: Path = SERVER_IDENTITY_PATH) -> Ed25519PrivateKey:
"""Load the persistent serve identity key, creating it on first start."""
if path.exists():
key = load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError(f"server identity key is not Ed25519: {path}")
return key
path.parent.mkdir(parents=True, exist_ok=True)
key = Ed25519PrivateKey.generate()
pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
fd = path.open("xb")
try:
fd.write(pem)
finally:
fd.close()
path.chmod(0o600)
return key
def public_key_hex(key: Ed25519PrivateKey) -> str:
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
def _signed_challenge_fields(challenge: dict) -> dict:
return {key: value for key, value in challenge.items() if key != "server_sig"}
def challenge_payload(challenge: dict) -> bytes:
"""Canonical bytes signed by the server identity key."""
return json.dumps(_signed_challenge_fields(challenge), sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_challenge(challenge: dict, key: Ed25519PrivateKey) -> str:
return key.sign(challenge_payload(challenge)).hex()
def verify_challenge_signature(challenge: dict) -> bool:
pub_hex = challenge.get("server_pubkey")
sig_hex = challenge.get("server_sig")
if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
return False
try:
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
pub.verify(bytes.fromhex(sig_hex), challenge_payload(challenge))
return True
except (InvalidSignature, ValueError):
return False
+37 -5
View File
@@ -151,21 +151,25 @@ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_p
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
return targets
def _cached_client_row(target: BrowserTarget) -> dict | None:
def _cached_client_row(target: BrowserTarget, *, scoped: bool = False) -> dict | None:
"""Build a clients row from a target's discovery data, skipping a roundtrip.
Returns None when the remote didn't advertise its version (older serve), so
callers fall back to an explicit ``clients.list`` query.
callers fall back to an explicit ``clients.list`` query. When *scoped* is
true, the caller already selected one remote host, so render profile-only
labels instead of adding a host group header.
"""
if target.version is None and target.extension_version is None:
return None
return {
"profile": target.display_name,
"profileGroup": target.display_group,
row = {
"profile": target.profile if scoped else target.display_name,
"name": target.browser_name or "",
"version": target.version or "",
"extensionVersion": target.extension_version or "",
}
if target.display_group and not scoped:
row["profileGroup"] = target.display_group
return row
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
rows = []
@@ -243,6 +247,34 @@ def collect_browser_clients(
return rows
if remote:
targets = remote_browser_targets(remote, key=key)
if browser_alias:
targets = [target for target in targets if target.profile == browser_alias or target.display_name == browser_alias]
if targets:
uncached = []
for target in targets:
cached = _cached_client_row(target, scoped=True)
if cached is not None:
rows.append(cached)
else:
uncached.append(target)
results = _run_concurrent([
(lambda t=t: _client_rows_async(
t.profile,
profile=t.profile,
remote=remote,
key=key,
))
for t in uncached
])
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
return rows
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for item in result or []:
row = dict(item)
+2 -2
View File
@@ -5,8 +5,8 @@ import uuid
from typing import Any
from browser_cli import transport
from browser_cli.endpoints import _normalize_endpoint
from browser_cli.errors import BrowserNotConnected
from browser_cli.remote.registry import resolve_remote_endpoint
def base_message(command: str, args: dict | None) -> dict:
return {"id": str(uuid.uuid4()), "command": command, "args": args or {}}
@@ -14,7 +14,7 @@ def base_message(command: str, args: dict | None) -> dict:
def requested_target(profile: str | None, remote: str | None) -> tuple[str | None, str | None]:
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
return requested_profile, _normalize_endpoint(remote_endpoint) if remote_endpoint else None
return requested_profile, resolve_remote_endpoint(remote_endpoint) if remote_endpoint else None
def encode_payload(msg: dict) -> bytes:
return json.dumps(msg).encode("utf-8")
+95 -20
View File
@@ -1,18 +1,53 @@
from __future__ import annotations
import json
import click
from rich.console import Console
from rich.table import Table
from browser_cli.errors import BrowserNotConnected
from browser_cli import BrowserCLI
from browser_cli.commands import handle_errors
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
from browser_cli.remote.known_hosts import fingerprint, load_known_hosts, remove_known_host, save_known_host
from browser_cli.remote.registry import load_remotes, remove_remote, save_remote, save_remote_key
console = Console()
def _print_remotes() -> None:
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint")
table.add_column("Key")
for endpoint, cfg in sorted(remotes.items()):
table.add_row(endpoint, str(cfg.get("key", "")))
console.print(table)
def _remove_remote(endpoint: str, *, verb: str) -> None:
if not remove_remote(endpoint):
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
console.print(f"[green]{verb} {endpoint}[/green]")
def _fetch_server_pubkey(endpoint: str) -> str:
from browser_cli.auth.server_identity import verify_challenge_signature
from browser_cli.remote.auth import parse_challenge
from browser_cli.remote.socket import connect_socket, recv_all
sock = connect_socket(endpoint)
try:
challenge, _nonce = parse_challenge(recv_all(sock) or b"")
finally:
sock.close()
if not isinstance(challenge, dict) or not isinstance(challenge.get("server_pubkey"), str):
raise BrowserNotConnected("remote server did not advertise a server identity key")
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("remote server identity signature is invalid")
return str(challenge["server_pubkey"])
@click.group("remote")
def remote_group():
"""Manage remembered browser-cli remote endpoints."""
@@ -42,6 +77,17 @@ def remote_status(endpoint, key):
browser_header="Profile",
)
@remote_group.command("add")
@click.argument("endpoint")
@click.option("--key", "key_spec", default=None, help="Key spec/path to remember for this endpoint")
def remote_add(endpoint, key_spec):
"""Remember a remote endpoint for global multi-browser commands."""
save_remote(endpoint, key_spec)
if key_spec:
console.print(f"[green]Added remote {endpoint} with key {key_spec}[/green]")
else:
console.print(f"[green]Added remote {endpoint}[/green]")
@remote_group.command("trust")
@click.argument("endpoint")
@click.argument("key_spec")
@@ -50,29 +96,58 @@ def remote_trust(endpoint, key_spec):
save_remote_key(endpoint, key_spec)
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
@remote_group.command("list")
def remote_list():
"""List remembered remote endpoints."""
_print_remotes()
@remote_group.command("trust-host")
@click.argument("endpoint")
@click.option("--pubkey", default=None, help="Pin this server public key instead of probing the endpoint")
@handle_errors
def remote_trust_host(endpoint, pubkey):
"""Pin a remote server identity key, SSH known_hosts style."""
server_pubkey = pubkey or _fetch_server_pubkey(endpoint)
save_known_host(endpoint, server_pubkey)
console.print(f"[green]Trusted server {endpoint}[/green] [dim]{fingerprint(server_pubkey)}[/dim]")
@remote_group.command("known-hosts")
def remote_known_hosts():
"""List pinned remote server identity keys."""
known = load_known_hosts()
if not known:
console.print("[yellow]No known remote server identities[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint")
table.add_column("Key")
for endpoint, cfg in sorted(remotes.items()):
table.add_row(endpoint, str(cfg.get("key", "")))
table.add_column("Fingerprint")
table.add_column("Public Key")
for endpoint, pubkey in sorted(known.items()):
table.add_row(endpoint, fingerprint(pubkey), pubkey)
console.print(table)
@remote_group.command("untrust-host")
@click.argument("endpoint")
def remote_untrust_host(endpoint):
"""Remove a pinned remote server identity key."""
if not remove_known_host(endpoint):
console.print(f"[yellow]Remote server {endpoint} is not in known hosts[/yellow]")
return
console.print(f"[green]Removed server identity for {endpoint}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
_print_remotes()
@remote_group.command("remove")
@click.argument("endpoint")
def remote_remove(endpoint):
"""Remove a remembered remote endpoint."""
_remove_remote(endpoint, verb="Removed")
@remote_group.command("revoke")
@click.argument("endpoint")
def remote_revoke(endpoint):
"""Remove remembered key/config for ENDPOINT."""
remotes = load_remotes()
if endpoint not in remotes:
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
del remotes[endpoint]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
console.print(f"[green]Revoked {endpoint}[/green]")
_remove_remote(endpoint, verb="Revoked")
+13 -33
View File
@@ -3,48 +3,28 @@ 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)
("X.Y.Z", transformer_fn)
Entries must stay in ascending version order.
The registry is intentionally empty: the first public release was 0.14.1, so no
legacy-client shim has ever been needed. This module is the seam add a tuple
here (and a unit test for it) the day a breaking auth-field change ships.
"""
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") in {"browser-cli.auth.trust", "browser-cli.auth.policy"}:
args = msg.get("args") or {}
trust_pk = args.get("pubkey")
identifier = args.get("identifier")
patched = dict(args)
if isinstance(trust_pk, str) and trust_pk:
patched["pubkey"] = trust_pk.lower()
if isinstance(identifier, str) and identifier and len(identifier) == 64:
patched["identifier"] = identifier.lower()
if patched != args:
changed["args"] = patched
return {**msg, **changed} if changed else msg
# ── registry ──────────────────────────────────────────────────────────────────
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
("0.9.3", _auth_0_9_3),
]
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = []
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)
"""Apply all auth normalizers needed to bring msg up to the current format."""
if not _AUTH_COMPAT:
return msg
cv = parse_version(client_version)
for version, fn in _AUTH_COMPAT:
if cv < parse_version(version):
msg = fn(msg)
return msg
+19 -16
View File
@@ -3,7 +3,7 @@ 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)
("X.Y.Z", request_fn, response_fn)
- request_fn(msg: dict) -> dict or None
- response_fn(resp: bytes, command: str) -> bytes or None
@@ -11,33 +11,36 @@ Add one entry per breaking command-format change:
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.
The registry is intentionally empty: no command-format shim has been needed
since the first public release (0.14.1). This module is the seam add a tuple
here (and a unit test for it) the day a breaking command-format change ships.
"""
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),
# ("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)
"""Upgrade a client message to the current browser command format."""
if not _COMPAT:
return msg
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)
"""Downgrade a native-host response to the format the client expects."""
if not _COMPAT:
return resp
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
+5
View File
@@ -0,0 +1,5 @@
"""Stateless Model Context Protocol adapter for browser-cli."""
from browser_cli.mcp.server import create_server, main
__all__ = ["create_server", "main"]
+39
View File
@@ -0,0 +1,39 @@
"""Tool-name prefixing for the MCP surface.
Hosts differ in how they namespace MCP tools. Hosts that already prefix tool
names with the server name turn the built-in ``browser_`` prefix into stutter
(``browser_cli_testing_browser_tabs_list``), while hosts that expose raw names
need the prefix to keep ``navigate`` or ``screenshot`` unambiguous. The prefix
is therefore configurable per MCP server process.
"""
from __future__ import annotations
import os
import re
TOOL_PREFIX_ENV = "BROWSER_CLI_MCP_TOOL_PREFIX"
DEFAULT_TOOL_PREFIX = "browser_"
_VALID_PREFIX = re.compile(r"\A[a-z][a-z0-9_]*\Z")
def resolve_tool_prefix(environ: dict[str, str] | None = None) -> str:
"""Return the configured tool-name prefix, defaulting to ``browser_``.
An empty value disables prefixing for hosts that namespace tools themselves.
"""
configured = (environ if environ is not None else os.environ).get(TOOL_PREFIX_ENV)
if configured is None:
return DEFAULT_TOOL_PREFIX
prefix = configured.strip()
if not prefix:
return ""
if not _VALID_PREFIX.match(prefix):
raise ValueError(
f"{TOOL_PREFIX_ENV} must be lowercase letters, digits, and underscores starting "
f"with a letter, or empty to disable prefixing; got {configured!r}"
)
return prefix if prefix.endswith("_") else f"{prefix}_"
def tool_name(base: str, prefix: str) -> str:
"""Apply *prefix* to a bare tool name such as ``tabs_list``."""
return f"{prefix}{base}"
+19
View File
@@ -0,0 +1,19 @@
"""Convert browser-cli SDK models into MCP structured-output values."""
from __future__ import annotations
from dataclasses import fields, is_dataclass
from typing import Any
def structured(value: Any) -> Any:
"""Return JSON-compatible data without private SDK binding fields."""
if is_dataclass(value) and not isinstance(value, type):
return {
field.name: structured(getattr(value, field.name))
for field in fields(value)
if not field.name.startswith("_")
}
if isinstance(value, dict):
return {str(key): structured(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [structured(item) for item in value]
return value
+231
View File
@@ -0,0 +1,231 @@
"""Stateless MCP server exposing a conservative browser-cli tool surface.
The MCP process stores no browser client, tab ID, or navigation state. Every
call constructs a fresh :class:`browser_cli.BrowserCLI`; the real browser is
the sole owner of browser state.
"""
from __future__ import annotations
import argparse
import base64
import os
from collections.abc import Callable
from typing import Any, Literal
from urllib.parse import urlsplit
from browser_cli import BrowserCLI
from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.targets import resolve_tab_id
ClientFactory = Callable[..., BrowserCLI]
_SERVER_INSTRUCTIONS = """Control a real, user-visible browser through browser-cli.
The server is stateless: pass browser, remote, and key on each tool call when a
specific target is required. Tool calls affect the user's actual browser. Read
current tabs/page state instead of assuming IDs or content from an earlier call.
"""
def _client(factory: ClientFactory, browser: str | None, remote: str | None, key: str | None) -> BrowserCLI:
"""Build a fresh client, making MCP process environment defaults explicit.
Explicit values are important for SDK multi-browser routing: leaving
``browser=None`` would fan out list/count calls before lower transport code
gets a chance to consult ``BROWSER_CLI_PROFILE``.
"""
return factory(
browser=browser or os.environ.get("BROWSER_CLI_PROFILE"),
remote=remote or os.environ.get("BROWSER_CLI_REMOTE"),
key=key or os.environ.get("BROWSER_CLI_KEY"),
)
def _screenshot_bytes(data_url: str) -> tuple[bytes, str]:
"""Decode a browser screenshot data URL into bytes and an MCP image format."""
header, separator, payload = data_url.partition(",")
if not separator or ";base64" not in header:
raise ValueError("Browser returned an invalid screenshot data URL")
media_type = header[5:].split(";", 1)[0].lower()
image_format = "jpeg" if media_type in {"image/jpeg", "image/jpg"} else "png"
return base64.b64decode(payload, validate=True), image_format
def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: str | None = None):
"""Create the MCP server. Supplying *client_factory* keeps tests browser-free."""
try:
from mcp.server import MCPServer
from mcp.server.mcpserver import Image
except ImportError as exc: # pragma: no cover - exercised without the optional extra
raise RuntimeError(
"MCP support is not installed. Install real-browser-cli with the 'mcp' extra: "
"uv tool install 'real-browser-cli[mcp]'"
) from exc
prefix = resolve_tool_prefix() if tool_prefix is None else tool_prefix
mcp = MCPServer(
"browser-cli",
description="Control a real running browser through the browser-cli SDK.",
instructions=_SERVER_INSTRUCTIONS,
)
@mcp.tool(name=tool_name("tabs_list", prefix))
def tabs_list(
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> list[dict[str, Any]]:
"""List current tabs. Optionally target a browser alias or authenticated remote."""
return structured(_client(client_factory, browser, remote, key).tabs.list())
@mcp.tool(name=tool_name("tabs_open", prefix))
def tabs_open(
url: str,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Open a URL in a new real-browser tab and return its current metadata."""
tab = _client(client_factory, browser, remote, key).tabs.open(
url, wait=wait, timeout=timeout, background=background, focus=focus
)
return structured(tab)
@mcp.tool(name=tool_name("tabs_close", prefix))
def tabs_close(
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, int]:
"""Close a tab, defaulting to the active tab. This changes the real browser."""
client = _client(client_factory, browser, remote, key)
target = resolve_tab_id(client, tab_id)
return {"closed": client.tabs.close(target), "tab_id": target}
@mcp.tool(name=tool_name("navigate", prefix))
def navigate(
url: str,
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Navigate a tab to a URL, defaulting to the active tab, and return it."""
client = _client(client_factory, browser, remote, key)
target = resolve_tab_id(client, tab_id)
client.nav.to(target, url)
return structured(client.tabs.status(target))
@mcp.tool(name=tool_name("page_info", prefix))
def page_info(
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Return title, URL, readiness, language, and metadata for the active page."""
return structured(_client(client_factory, browser, remote, key).page.info())
@mcp.tool(name=tool_name("extract_text", prefix))
def extract_text(
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> str:
"""Extract plain text from the active page."""
return _client(client_factory, browser, remote, key).extract.text()
@mcp.tool(name=tool_name("extract_markdown", prefix))
def extract_markdown(
selector: str | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> str:
"""Extract clean Markdown from the active page or an optional CSS selector."""
return _client(client_factory, browser, remote, key).extract.markdown(selector)
@mcp.tool(name=tool_name("dom_query", prefix))
def dom_query(
selector: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> list[dict[str, Any]]:
"""Return elements matching a CSS selector on the active page."""
return structured(_client(client_factory, browser, remote, key).dom.query(selector))
@mcp.tool(name=tool_name("dom_click", prefix))
def dom_click(
selector: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Click the first matching element, then return current active-page info."""
client = _client(client_factory, browser, remote, key)
client.dom.click(selector)
return structured(client.page.info())
@mcp.tool(name=tool_name("dom_type", prefix))
def dom_type(
selector: str,
text: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, bool]:
"""Type text into the first element matching a CSS selector."""
_client(client_factory, browser, remote, key).dom.type(selector, text)
return {"typed": True}
@mcp.tool(name=tool_name("screenshot", prefix), structured_output=False)
def screenshot(
tab_id: int | None = None,
format: Literal["png", "jpeg"] = "png",
quality: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> Any:
"""Capture the visible area of the active or specified tab as an image."""
data_url = _client(client_factory, browser, remote, key).tabs.screenshot(
tab_id, format=format, quality=quality
)
data, actual_format = _screenshot_bytes(data_url)
return Image(data=data, format=actual_format)
return mcp
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the stateless browser-cli MCP server.")
parser.add_argument("--transport", choices=("stdio", "streamable-http"), default="stdio")
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (streamable-http only).")
parser.add_argument("--port", type=int, default=8000, help="HTTP bind port (streamable-http only).")
parser.add_argument("--path", default="/mcp", help="MCP endpoint path (streamable-http only).")
return parser
def main(argv: list[str] | None = None) -> None:
"""Run over stdio, or stateless Streamable HTTP when explicitly selected."""
args = _parser().parse_args(argv)
mcp = create_server()
if args.transport == "stdio":
mcp.run()
return
if args.host not in {"127.0.0.1", "localhost", "::1"}:
raise SystemExit(
"Refusing to expose the unauthenticated MCP server beyond localhost. "
"Use browser-cli's authenticated remote transport from a local MCP server instead."
)
mcp.run(
transport="streamable-http",
host=args.host,
port=args.port,
streamable_http_path=args.path,
stateless_http=True,
json_response=True,
)
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
"""Tab targeting for the MCP surface.
MCP callers pay a full round trip for every extra tool call, so tools that act
on a tab accept an optional ``tab_id`` and fall back to the browser's current
active tab. Resolution happens here rather than by forwarding ``None`` into the
SDK, so the acting tool always knows which tab it touched and can report it.
"""
from __future__ import annotations
from browser_cli import BrowserCLI
def resolve_tab_id(client: BrowserCLI, tab_id: int | None) -> int:
"""Return *tab_id*, or the ID of the currently active tab when it is ``None``."""
if tab_id is not None:
return tab_id
return client.tabs.active().id
+108
View File
@@ -0,0 +1,108 @@
"""SSH-style known-hosts pinning for browser-cli remote servers."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from browser_cli.constants import CONFIG_DIR
from browser_cli.endpoints import _normalize_endpoint
from browser_cli.errors import BrowserNotConnected
KNOWN_HOSTS_PATH = CONFIG_DIR / "known_hosts.json"
def fingerprint(pubkey_hex: str) -> str:
"""Return a compact SHA256 fingerprint for display."""
import base64
import hashlib
digest = hashlib.sha256(bytes.fromhex(pubkey_hex)).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
def load_known_hosts(path: Path | None = None) -> dict[str, str]:
path = path or KNOWN_HOSTS_PATH
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {_normalize_endpoint(str(endpoint)): str(pubkey) for endpoint, pubkey in data.items() if isinstance(pubkey, str)}
def save_known_host(endpoint: str, pubkey_hex: str, path: Path | None = None) -> None:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
known[_normalize_endpoint(endpoint)] = pubkey_hex
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
def remove_known_host(endpoint: str, path: Path | None = None) -> bool:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
normalized = _normalize_endpoint(endpoint)
if normalized not in known:
return False
del known[normalized]
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
return True
def _is_loopback_endpoint(endpoint: str) -> bool:
host, sep, _port = endpoint.rpartition(":")
check = host if sep else endpoint
return check in {"127.0.0.1", "localhost", "::1"}
def verify_known_host(endpoint: str, challenge: dict | None) -> None:
"""Verify and pin the server identity from a challenge frame.
First contact auto-adds the host when the process is interactive, mirroring
SSH's trust-on-first-use flow. Non-interactive clients must pin explicitly via
`browser-cli remote trust-host ENDPOINT`.
"""
if not isinstance(challenge, dict):
return
pubkey = challenge.get("server_pubkey")
if not isinstance(pubkey, str) or not pubkey:
return
from browser_cli.auth.server_identity import verify_challenge_signature
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("Remote server identity signature is invalid")
normalized = _normalize_endpoint(endpoint)
known = load_known_hosts()
expected = known.get(normalized)
if expected is None and _is_loopback_endpoint(endpoint):
return
if expected is None:
if not sys.stdin.isatty():
raise BrowserNotConnected(
f"Unknown remote server identity for {normalized} ({fingerprint(pubkey)}).\n"
f"Run: browser-cli remote trust-host {normalized}"
)
sys.stderr.write(
f"The authenticity of remote '{normalized}' can't be established.\n"
f"Server key fingerprint is {fingerprint(pubkey)}.\n"
"Trust this server and add it to known hosts? [y/N] "
)
answer = sys.stdin.readline().strip().lower()
if answer not in {"y", "yes"}:
raise BrowserNotConnected("Remote server identity was not trusted")
save_known_host(normalized, pubkey)
sys.stderr.write(f"Added {normalized} to browser-cli known hosts.\n")
return
if expected != pubkey:
raise BrowserNotConnected(
f"REMOTE SERVER IDENTITY CHANGED for {normalized}!\n"
f"Known: {fingerprint(expected)}\n"
f"Seen: {fingerprint(pubkey)}\n"
f"If this is expected, run: browser-cli remote untrust-host {normalized} && browser-cli remote trust-host {normalized}"
)
+54 -7
View File
@@ -22,24 +22,71 @@ def load_remotes() -> dict[str, dict[str, str]]:
# 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 resolve_remote_endpoint(endpoint: str | None) -> str | None:
"""Resolve a user-supplied remote alias to a remembered endpoint.
Domain-like remotes without an explicit port still default to :443 when no
matching remembered remote exists. If the user remembered exactly one
explicit-port remote for the same host (for example
``browser-host.example:8765``), use that endpoint so ``--remote
browser-host.example`` targets the stored service instead of assuming HTTPS.
"""
if not endpoint:
return None
normalized = _normalize_endpoint(endpoint)
host, sep, _port = normalized.rpartition(":")
if sep:
return normalized
remotes = load_remotes()
explicit_matches = []
for remote_endpoint in remotes:
remote_host, remote_sep, remote_port = remote_endpoint.rpartition(":")
if remote_sep and remote_host == normalized and remote_port != "443":
explicit_matches.append(remote_endpoint)
if len(explicit_matches) == 1:
return explicit_matches[0]
return normalized
def is_valid_key_spec(value: str) -> bool:
"""Return True for 'agent', 'agent:<selector>', or a plausible key file path."""
return value == "agent" or value.startswith("agent:") or (
not value.startswith("<") and ("/" in value or Path(value).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 or not is_valid_key_spec(key_spec):
def save_remote(endpoint: str, key_spec: str | None = None) -> None:
"""Persist a remote endpoint, optionally with a key spec."""
if not endpoint:
return
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes()
current = remotes.get(endpoint, {})
current["key"] = key_spec
remotes[endpoint] = current
current = remotes.get(normalized, {})
if key_spec:
if not is_valid_key_spec(key_spec):
return
current["key"] = key_spec
remotes[normalized] = 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)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
def remove_remote(endpoint: str) -> bool:
"""Remove a remembered remote endpoint. Returns True when it existed."""
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes()
if normalized not in remotes:
return False
del remotes[normalized]
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)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
return True
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."""
save_remote(endpoint, key_spec)
def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
+7 -1
View File
@@ -28,6 +28,7 @@ from browser_cli.remote.socket import (
split_endpoint as _split_endpoint,
)
from browser_cli.remote import pool as _pool
from browser_cli.remote.known_hosts import verify_known_host
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
# Reuse an already-authenticated connection when one is idle for this endpoint.
@@ -51,7 +52,10 @@ def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_n
sock = _connect_socket(endpoint)
try:
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
challenge_raw = _recv_all(sock)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
payload_msg, pq_shared_secret = _with_challenge(challenge_raw, msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
except BaseException:
@@ -69,6 +73,8 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
reader, writer = await _open_async_connection(endpoint)
try:
challenge_raw = await _async_recv_all(reader)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
+4
View File
@@ -28,4 +28,8 @@ async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | No
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()}
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge
server_key = await asyncio.to_thread(load_or_create_server_identity)
challenge_msg["server_pubkey"] = public_key_hex(server_key)
challenge_msg["server_sig"] = sign_challenge(challenge_msg, server_key)
return nonce, pq_private_key, challenge_msg
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.16.3",
"version": "0.16.6",
"description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": {
"gecko": {
+22 -2
View File
@@ -4,6 +4,26 @@ import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types';
/** Convert a shell-style glob (`*` = any run, `?` = any single char) to an
* unanchored RegExp. Every other character is matched literally. Unanchored so
* `twitch.tv/*` matches anywhere inside `https://www.twitch.tv/foo`. */
function globToRegExp(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
return new RegExp(escaped.replace(/\*/g, '.*').replace(/\?/g, '.'));
}
/**
* Match a tab URL against a pattern. Backward-compatible: a pattern with no
* glob metacharacters is a plain case-sensitive substring match (the historic
* behavior); a pattern containing `*` or `?` is treated as a glob, so
* `twitch.tv/*` matches every Twitch tab.
*/
export function urlMatchesPattern(url: string | undefined, pattern: string): boolean {
if (!url || !pattern) return false;
if (/[*?]/.test(pattern)) return globToRegExp(pattern).test(url);
return url.includes(pattern);
}
export class TabsQueryCommands extends CommandGroup {
readonly namespace = "tabs";
readonly commands: Record<string, CommandEntry> = {
@@ -50,12 +70,12 @@ export class TabsQueryCommands extends CommandGroup {
private async tabsFilter({ pattern }: TabsPatternArgs) {
const all = await api.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
return all.filter(t => urlMatchesPattern(t.url, pattern)).map(tabInfo);
}
private async tabsCount({ pattern }: TabsPatternArgs) {
const all = await api.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
if (pattern) return all.filter(t => urlMatchesPattern(t.url, pattern)).length;
return all.length;
}
+43
View File
@@ -0,0 +1,43 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { urlMatchesPattern } from '../src/commands/tabs-query';
const TWITCH = 'https://www.twitch.tv/somechannel';
test('plain pattern is a case-sensitive substring match (historic behavior)', () => {
assert.equal(urlMatchesPattern(TWITCH, 'twitch.tv'), true);
assert.equal(urlMatchesPattern(TWITCH, 'somechannel'), true);
assert.equal(urlMatchesPattern(TWITCH, 'Twitch.tv'), false, 'case-sensitive');
assert.equal(urlMatchesPattern(TWITCH, 'youtube.com'), false);
});
test('glob with /* matches anywhere in the URL', () => {
// The reported case: a glob, not a literal substring.
assert.equal(urlMatchesPattern(TWITCH, 'twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://www.twitch.tv/', 'twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://twitch.tv', 'twitch.tv/*'), false, 'no slash → no match');
});
test('leading wildcard and ? wildcard work', () => {
assert.equal(urlMatchesPattern(TWITCH, '*.twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://a.twitch.tv/x', 'https://?.twitch.tv/*'), true);
assert.equal(urlMatchesPattern('https://ab.twitch.tv/x', 'https://?.twitch.tv/*'), false, '? is one char');
});
test('regex metacharacters in a non-glob pattern stay literal', () => {
assert.equal(urlMatchesPattern('https://x.dev/a.b', 'a.b'), true);
assert.equal(urlMatchesPattern('https://x.dev/axb', 'a.b'), false, 'plain substring is literal — "." is not a regex wildcard');
});
test('regex metacharacters next to a glob are escaped', () => {
// The "." must stay literal even when "*" promotes the pattern to a glob.
assert.equal(urlMatchesPattern('https://x.dev/foo', 'x.dev/*'), true);
assert.equal(urlMatchesPattern('https://xydev/foo', 'x.dev/*'), false, '. does not match y');
});
test('empty url or pattern never matches', () => {
assert.equal(urlMatchesPattern('', 'twitch.tv'), false);
assert.equal(urlMatchesPattern(undefined, 'twitch.tv'), false);
assert.equal(urlMatchesPattern(TWITCH, ''), false);
});
+42 -7
View File
@@ -44,27 +44,56 @@ Paste the contents of `n8n_key.pem` into the n8n credential.
| Port | `serve` TCP port (default `8765`) |
| Ed25519 Private Key | PKCS8 PEM from `browser-cli auth keygen` (empty only for `--no-auth` loopback) |
| Browser Alias | optional `_route` target — required if the endpoint serves multiple browsers |
| Server Public Key/Fingerprint | pinned `browser-cli serve` identity (`SHA256:...` fingerprint or 64-char server public key hex) |
| Allow Unknown Server Identity | disables SSH-style server pinning; use only for loopback/dev |
| Use TLS | wrap the connection in TLS (only for a TLS-terminating proxy; the protocol is already encrypted) |
| Ignore SSL Issues | when TLS is on, accept a self-signed proxy cert |
### Server identity pinning
Recent `browser-cli serve` versions advertise a persistent Ed25519 server
identity in the challenge frame. The n8n node verifies the challenge signature
and compares the key against the credential's **Server Public Key/Fingerprint**
field, similar to SSH `known_hosts`.
On a trusted machine, pin the server once with the Python CLI and copy the
fingerprint into the n8n credential:
```bash
browser-cli remote trust-host browser-host.example:8765
browser-cli remote known-hosts
```
If the server key changes, the node fails with `REMOTE SERVER IDENTITY CHANGED`.
Only enable **Allow Unknown Server Identity** for local/dev endpoints where you
explicitly do not want pinning.
## Operations
Every operation maps to one raw browser-cli command, each subject to the server
policy tier noted below.
| Resource | Operation | Command | Server flag needed |
|----------|-----------|---------|--------------------|
| Tab | List | `tabs.list` | safe (default) |
| Tab | Open | `navigate.open` | `--allow-control` |
| Tab | Close | `tabs.close` (ids / inactive / duplicates) | `--allow-control` |
| Tab | List / Query / Get / Count / Filter / Active in Window | `tabs.list` / `tabs.query` / `tabs.status` / `tabs.count` / `tabs.filter` / `tabs.active_in_window` | safe (default) |
| Tab | Get HTML | `tabs.html` | `--allow-read-page` |
| Tab | Open / Close / Activate / Move / Navigate To / Reload / Hard Reload / Back / Forward | `navigate.open` / `tabs.close` / `tabs.active` / `tabs.move` / `navigate.to` / `navigate.reload` / `navigate.hard_reload` / `navigate.back` / `navigate.forward` | `--allow-control` |
| Tab | Mute / Unmute / Pin / Unpin / Dedupe / Sort / Merge Windows | `tabs.mute` / `tabs.unmute` / `tabs.pin` / `tabs.unpin` / `tabs.dedupe` / `tabs.sort` / `tabs.merge_windows` | `--allow-control` |
| Tab | Screenshot | `tabs.screenshot` | `--allow-dangerous` |
| Page | Get Info | `page.info` | safe (default) |
| Page | Extract Text / Links / Images / HTML / Markdown | `extract.*` | `--allow-read-page` |
| DOM | Query | `dom.query` | `--allow-read-page` |
| DOM | Click / Type | `dom.click` / `dom.type` | `--allow-control` |
| Page | Extract Text / Links / Images / HTML / Markdown / JSON | `extract.*` | `--allow-read-page` |
| DOM | Query / Text / Attribute / Exists | `dom.query` / `dom.text` / `dom.attr` / `dom.exists` | `--allow-read-page` |
| DOM | Click / Type / Select / Hover / Focus / Check / Uncheck / Clear / Submit / Scroll / Key | `dom.*` | `--allow-control` |
| DOM | Eval | `dom.eval` | `--allow-dangerous` |
| Group | List / Query / Tabs | `group.list` / `group.query` / `group.tabs` | safe (default) |
| Group | Count / Create / Add Tab / Move / Close | `group.count` / `group.open` / `group.add_tab` / `group.move` / `group.close` | `--allow-control` |
| Window | List | `windows.list` | safe (default) |
| Window | Open / Close / Rename | `windows.open` / `windows.close` / `windows.rename` | `--allow-control` |
| Session | List / Save / Load / Remove / Export / Diff / Auto Save | `session.*` | `--allow-control` |
| Storage | Get / Set | `storage.get` / `storage.set` | `--allow-dangerous` |
| Performance | Status | `perf.status` | safe (default) |
| Extension | Info / Capabilities | `extension.info` / `extension.capabilities` | safe (default) |
| Extension | Reload | `extension.reload` | `--allow-control` |
| Client | List | `clients.list` | safe (default) |
| Command | Execute | any command name + JSON args | per command |
| Gateway | Health | pings with `tabs.list` | safe (default) |
**Command → Execute** is the escape hatch: any command string the server policy
allows (`tabs.query`, `session.save`, `windows.list`, …) with a JSON args object.
@@ -74,6 +103,12 @@ Use it for anything the typed operations don't cover.
> `extract.markdown` therefore returns the page payload as the extension hands it
> back, not the CLI's rendered Markdown. For clean text use **Extract Text**.
> **Tab → Filter / Count URL Pattern:** matched against the full tab URL. A plain
> string is a case-sensitive substring (`twitch.tv`); a pattern containing `*` or
> `?` is a glob (`twitch.tv/*`, `*.twitch.tv`). Glob needs the serve-side extension
> at **0.16.4+**; older extensions treat the whole pattern as a literal substring,
> so `twitch.tv/*` matches nothing there — use `twitch.tv` instead.
## Develop / build
```bash
cd n8n-nodes-browser-cli
@@ -65,6 +65,23 @@ export class BrowserCliApi implements ICredentialType {
description:
'Optional browser alias to route to (the serve `_route` target). Required when the serve endpoint exposes multiple browser instances; leave empty for a single-browser serve.',
},
{
displayName: 'Server Public Key/Fingerprint',
name: 'serverIdentity',
type: 'string',
default: '',
placeholder: 'SHA256:... or 64-char Ed25519 public key hex',
description:
'Pinned browser-cli serve identity. Get it with `browser-cli remote trust-host ENDPOINT` / `browser-cli remote known-hosts`, then paste the SHA256 fingerprint or raw server public key here. Required for non-loopback endpoints.',
},
{
displayName: 'Allow Unknown Server Identity',
name: 'allowUnknownServerIdentity',
type: 'boolean',
default: false,
description:
'Whether to connect without a pinned server identity. Only use for local development/loopback; disabling pinning weakens SSH-style host verification.',
},
{
displayName: 'Use TLS',
name: 'tls',
@@ -26,7 +26,7 @@ export class BrowserCli implements INodeType {
icon: 'file:browserCli.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
subtitle: '={{({ tab: "Tab", page: "Page", dom: "DOM", group: "Group", window: "Window", session: "Session", storage: "Storage", perf: "Perf", extension: "Extension", client: "Client", command: "Command" }[$parameter["resource"]] || $parameter["resource"]) + ": " + $parameter["operation"]}}',
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
defaults: { name: 'Browser CLI' },
inputs: [NodeConnectionTypes.Main],
@@ -43,9 +43,14 @@ export class BrowserCli implements INodeType {
{ name: 'Tab', value: 'tab' },
{ name: 'Page', value: 'page' },
{ name: 'DOM', value: 'dom' },
{ name: 'Group', value: 'group' },
{ name: 'Window', value: 'window' },
{ name: 'Session', value: 'session' },
{ name: 'Storage', value: 'storage' },
{ name: 'Performance', value: 'perf' },
{ name: 'Extension', value: 'extension' },
{ name: 'Client', value: 'client' },
{ name: 'Command', value: 'command' },
{ name: 'Gateway', value: 'gateway' },
],
default: 'tab',
},
@@ -59,9 +64,29 @@ export class BrowserCli implements INodeType {
displayOptions: { show: { resource: ['tab'] } },
options: [
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
{ name: 'Query', value: 'query', action: 'Search tabs by text', description: 'tabs.query (safe)' },
{ name: 'Get', value: 'get', action: 'Get a tab status', description: 'tabs.status (safe)' },
{ name: 'Count', value: 'count', action: 'Count open tabs', description: 'tabs.count (safe)' },
{ name: 'Filter', value: 'filter', action: 'Filter tabs by URL pattern', description: 'tabs.filter (safe)' },
{ name: 'Active in Window', value: 'activeInWindow', action: 'Get active tab in a window', description: 'tabs.active_in_window (safe)' },
{ name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' },
{ name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' },
{ name: 'Activate', value: 'activate', action: 'Switch focus to a tab', description: 'tabs.active (needs --allow-control)' },
{ name: 'Move', value: 'move', action: 'Move a tab', description: 'tabs.move (needs --allow-control)' },
{ name: 'Navigate To', value: 'navigateTo', action: 'Navigate a tab to a URL', description: 'navigate.to (needs --allow-control)' },
{ name: 'Reload', value: 'reload', action: 'Reload a tab', description: 'navigate.reload (needs --allow-control)' },
{ name: 'Hard Reload', value: 'hardReload', action: 'Hard reload a tab', description: 'navigate.hard_reload (needs --allow-control)' },
{ name: 'Back', value: 'back', action: 'Go back in history', description: 'navigate.back (needs --allow-control)' },
{ name: 'Forward', value: 'forward', action: 'Go forward in history', description: 'navigate.forward (needs --allow-control)' },
{ name: 'Mute', value: 'mute', action: 'Mute a tab', description: 'tabs.mute (needs --allow-control)' },
{ name: 'Unmute', value: 'unmute', action: 'Unmute a tab', description: 'tabs.unmute (needs --allow-control)' },
{ name: 'Pin', value: 'pin', action: 'Pin a tab', description: 'tabs.pin (needs --allow-control)' },
{ name: 'Unpin', value: 'unpin', action: 'Unpin a tab', description: 'tabs.unpin (needs --allow-control)' },
{ name: 'Dedupe', value: 'dedupe', action: 'Close duplicate tabs', description: 'tabs.dedupe (needs --allow-control)' },
{ name: 'Sort', value: 'sort', action: 'Sort tabs within windows', description: 'tabs.sort (needs --allow-control)' },
{ name: 'Merge Windows', value: 'mergeWindows', action: 'Merge all tabs into one window', description: 'tabs.merge_windows (needs --allow-control)' },
{ name: 'Screenshot', value: 'screenshot', action: 'Capture a tab screenshot', description: 'tabs.screenshot (needs --allow-dangerous)' },
],
default: 'list',
},
@@ -80,6 +105,7 @@ export class BrowserCli implements INodeType {
{ name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' },
{ name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' },
{ name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' },
{ name: 'Extract JSON', value: 'extractJson', action: 'Extract JSON-LD / structured data', description: 'extract.json (needs --allow-read-page)' },
],
default: 'extractText',
},
@@ -93,13 +119,122 @@ export class BrowserCli implements INodeType {
displayOptions: { show: { resource: ['dom'] } },
options: [
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
{ name: 'Text', value: 'text', action: 'Get element text', description: 'dom.text (needs --allow-read-page)' },
{ name: 'Attribute', value: 'attr', action: 'Get an element attribute', description: 'dom.attr (needs --allow-read-page)' },
{ name: 'Exists', value: 'exists', action: 'Check if an element exists', description: 'dom.exists (needs --allow-read-page)' },
{ name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' },
{ name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' },
{ name: 'Select', value: 'select', action: 'Select a dropdown option', description: 'dom.select (needs --allow-control)' },
{ name: 'Hover', value: 'hover', action: 'Hover over an element', description: 'dom.hover (needs --allow-control)' },
{ name: 'Focus', value: 'focus', action: 'Focus an element', description: 'dom.focus (needs --allow-control)' },
{ name: 'Check', value: 'check', action: 'Check a checkbox', description: 'dom.check (needs --allow-control)' },
{ name: 'Uncheck', value: 'uncheck', action: 'Uncheck a checkbox', description: 'dom.uncheck (needs --allow-control)' },
{ name: 'Clear', value: 'clear', action: 'Clear an input', description: 'dom.clear (needs --allow-control)' },
{ name: 'Submit', value: 'submit', action: 'Submit a form', description: 'dom.submit (needs --allow-control)' },
{ name: 'Scroll', value: 'scroll', action: 'Scroll to an element or position', description: 'dom.scroll (needs --allow-control)' },
{ name: 'Key', value: 'key', action: 'Send a keyboard key', description: 'dom.key (needs --allow-control)' },
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
],
default: 'query',
},
// --- Group operations -------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['group'] } },
options: [
{ name: 'List', value: 'list', action: 'List tab groups', description: 'group.list (safe)' },
{ name: 'Query', value: 'query', action: 'Search groups by name', description: 'group.query (safe)' },
{ name: 'Tabs', value: 'tabs', action: 'List tabs in a group', description: 'group.tabs (safe)' },
{ name: 'Count', value: 'count', action: 'Count tab groups', description: 'group.count (needs --allow-control)' },
{ name: 'Create', value: 'create', action: 'Create a tab group', description: 'group.open (needs --allow-control)' },
{ name: 'Add Tab', value: 'addTab', action: 'Add a tab to a group', description: 'group.add_tab (needs --allow-control)' },
{ name: 'Move', value: 'move', action: 'Move a group forward/backward', description: 'group.move (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close a tab group', description: 'group.close (needs --allow-control)' },
],
default: 'list',
},
// --- Window operations ------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['window'] } },
options: [
{ name: 'List', value: 'list', action: 'List browser windows', description: 'windows.list (safe)' },
{ name: 'Open', value: 'open', action: 'Open a new window', description: 'windows.open (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close a window', description: 'windows.close (needs --allow-control)' },
{ name: 'Rename', value: 'rename', action: 'Rename a window', description: 'windows.rename (needs --allow-control)' },
],
default: 'list',
},
// --- Session operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['session'] } },
options: [
{ name: 'List', value: 'list', action: 'List saved sessions', description: 'session.list (needs --allow-control)' },
{ name: 'Save', value: 'save', action: 'Save the current session', description: 'session.save (needs --allow-control)' },
{ name: 'Load', value: 'load', action: 'Load a saved session', description: 'session.load (needs --allow-control)' },
{ name: 'Remove', value: 'remove', action: 'Delete a saved session', description: 'session.remove (needs --allow-control)' },
{ name: 'Export', value: 'export', action: 'Export a session as JSON', description: 'session.export (needs --allow-control)' },
{ name: 'Diff', value: 'diff', action: 'Diff two sessions', description: 'session.diff (needs --allow-control)' },
{ name: 'Auto Save', value: 'autoSave', action: 'Toggle session auto-save', description: 'session.auto_save (needs --allow-control)' },
],
default: 'list',
},
// --- Storage operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['storage'] } },
options: [
{ name: 'Get', value: 'get', action: 'Read localStorage / sessionStorage', description: 'storage.get (needs --allow-dangerous)' },
{ name: 'Set', value: 'set', action: 'Write localStorage / sessionStorage', description: 'storage.set (needs --allow-dangerous)' },
],
default: 'get',
},
// --- Performance operations -------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['perf'] } },
options: [
{ name: 'Status', value: 'status', action: 'Get performance status', description: 'perf.status (safe)' },
],
default: 'status',
},
// --- Extension operations ---------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['extension'] } },
options: [
{ name: 'Info', value: 'info', action: 'Get extension info', description: 'extension.info (safe)' },
{ name: 'Capabilities', value: 'capabilities', action: 'List extension capabilities', description: 'extension.capabilities (safe)' },
{ name: 'Reload', value: 'reload', action: 'Reload the extension', description: 'extension.reload (needs --allow-control)' },
],
default: 'info',
},
// --- Client operations ------------------------------------------------
{
displayName: 'Operation',
@@ -126,18 +261,6 @@ export class BrowserCli implements INodeType {
default: 'execute',
},
// --- Gateway operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['gateway'] } },
options: [
{ name: 'Health', value: 'health', action: 'Check serve connectivity', description: 'Pings the endpoint with tabs.list (safe)' },
],
default: 'health',
},
// --- Shared parameter fields -----------------------------------------
{
@@ -147,7 +270,16 @@ export class BrowserCli implements INodeType {
default: '',
required: true,
placeholder: 'https://example.com',
displayOptions: { show: showFor('tab', ['open']) },
displayOptions: { show: showFor('tab', ['open', 'navigateTo']) },
},
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
placeholder: 'https://example.com',
description: 'Optional URL to open. Leave empty for a blank window/tab.',
displayOptions: { show: { resource: ['window', 'group'], operation: ['open', 'addTab'] } },
},
{
displayName: 'Focus Tab',
@@ -184,7 +316,136 @@ export class BrowserCli implements INodeType {
type: 'number',
default: 0,
description: 'Target tab ID. Leave 0 for the active tab.',
displayOptions: { show: { resource: ['tab'], operation: ['getHtml'] } },
displayOptions: {
show: {
resource: ['tab', 'dom'],
operation: ['getHtml', 'get', 'reload', 'hardReload', 'back', 'forward', 'mute', 'unmute', 'pin', 'unpin', 'screenshot', 'eval'],
},
},
},
{
displayName: 'Tab ID',
name: 'tabId',
type: 'number',
default: 0,
required: true,
description: 'Target tab ID (required — no active-tab fallback)',
displayOptions: { show: showFor('tab', ['activate', 'move', 'navigateTo']) },
},
{
displayName: 'Tab ID',
name: 'tabId',
type: 'number',
default: 0,
description: 'Target tab ID. Leave 0 for the active tab.',
displayOptions: { show: showFor('storage', ['get', 'set']) },
},
{
displayName: 'Search',
name: 'search',
type: 'string',
default: '',
required: true,
placeholder: 'github',
description: 'Substring to match against tab/group titles and URLs',
displayOptions: { show: { resource: ['tab', 'group'], operation: ['query'] } },
},
{
displayName: 'URL Pattern',
name: 'pattern',
type: 'string',
default: '',
placeholder: 'twitch.tv/* or twitch.tv',
description: 'Matched against the full tab URL. A plain string is a case-sensitive substring match ("twitch.tv"); a pattern with "*" or "?" is a glob ("twitch.tv/*", "*.twitch.tv"). Glob needs the serve-side extension at 0.16.4+; older extensions treat the whole pattern as a literal substring. Required for Filter; optional for Count (omit to count all).',
displayOptions: { show: showFor('tab', ['filter', 'count']) },
},
{
displayName: 'Window ID',
name: 'windowId',
type: 'number',
default: 0,
required: true,
displayOptions: { show: showFor('tab', ['activeInWindow']) },
},
{
displayName: 'Window ID',
name: 'windowId',
type: 'number',
default: 0,
required: true,
displayOptions: { show: showFor('window', ['close', 'rename']) },
},
{
displayName: 'Window ID',
name: 'windowId',
type: 'number',
default: 0,
description: 'Move the tab to this window. Leave 0 to keep it in the current window.',
displayOptions: { show: showFor('tab', ['move']) },
},
{
displayName: 'Index',
name: 'index',
type: 'number',
default: 0,
description: 'Target position within the window (0-based)',
displayOptions: { show: showFor('tab', ['move']) },
},
{
displayName: 'Format',
name: 'format',
type: 'options',
default: 'png',
options: [
{ name: 'PNG', value: 'png' },
{ name: 'JPEG', value: 'jpeg' },
],
displayOptions: { show: showFor('tab', ['screenshot']) },
},
{
displayName: 'Quality',
name: 'quality',
type: 'number',
default: 80,
description: 'JPEG quality 0-100 (ignored for PNG)',
displayOptions: { show: { resource: ['tab'], operation: ['screenshot'], format: ['jpeg'] } },
},
{
displayName: 'Gentle Mode',
name: 'gentleMode',
type: 'options',
default: 'auto',
description: 'How aggressively to rearrange tabs',
options: [
{ name: 'Auto', value: 'auto' },
{ name: 'On', value: 'on' },
{ name: 'Off', value: 'off' },
],
displayOptions: { show: showFor('tab', ['dedupe', 'sort', 'mergeWindows']) },
},
{
displayName: 'Gentle Mode',
name: 'gentleMode',
type: 'options',
default: 'auto',
options: [
{ name: 'Auto', value: 'auto' },
{ name: 'On', value: 'on' },
{ name: 'Off', value: 'off' },
],
displayOptions: { show: showFor('group', ['close']) },
},
{
displayName: 'Sort By',
name: 'by',
type: 'options',
default: 'domain',
options: [
{ name: 'Domain', value: 'domain' },
{ name: 'Title', value: 'title' },
{ name: 'Time', value: 'time' },
],
displayOptions: { show: showFor('tab', ['sort']) },
},
{
displayName: 'Selector',
@@ -196,7 +457,11 @@ export class BrowserCli implements INodeType {
displayOptions: {
show: {
resource: ['dom', 'page'],
operation: ['query', 'click', 'type', 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown'],
operation: [
'query', 'text', 'attr', 'exists', 'click', 'type', 'select', 'hover', 'focus',
'check', 'uncheck', 'clear', 'submit', 'scroll', 'key',
'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown', 'extractJson',
],
},
},
},
@@ -208,6 +473,51 @@ export class BrowserCli implements INodeType {
required: true,
displayOptions: { show: showFor('dom', ['type']) },
},
{
displayName: 'Attribute',
name: 'attr',
type: 'string',
default: '',
required: true,
placeholder: 'href',
description: 'Attribute name to read from the matched element',
displayOptions: { show: showFor('dom', ['attr']) },
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
required: true,
description: 'Option value to select in the dropdown',
displayOptions: { show: showFor('dom', ['select']) },
},
{
displayName: 'Key',
name: 'key',
type: 'string',
default: '',
required: true,
placeholder: 'Enter',
description: 'Keyboard key to send, e.g. Enter, Escape, ArrowDown',
displayOptions: { show: showFor('dom', ['key']) },
},
{
displayName: 'X',
name: 'x',
type: 'number',
default: 0,
description: 'Horizontal scroll position (used when no selector is given)',
displayOptions: { show: showFor('dom', ['scroll']) },
},
{
displayName: 'Y',
name: 'y',
type: 'number',
default: 0,
description: 'Vertical scroll position (used when no selector is given)',
displayOptions: { show: showFor('dom', ['scroll']) },
},
{
displayName: 'JavaScript',
name: 'code',
@@ -220,12 +530,97 @@ export class BrowserCli implements INodeType {
displayOptions: { show: showFor('dom', ['eval']) },
},
{
displayName: 'Tab ID',
name: 'tabId',
displayName: 'Group ID',
name: 'groupId',
type: 'number',
default: 0,
description: 'Target tab ID. Leave 0 for the active tab.',
displayOptions: { show: { resource: ['dom'], operation: ['eval'] } },
required: true,
displayOptions: { show: showFor('group', ['tabs', 'close']) },
},
{
displayName: 'Group',
name: 'group',
type: 'string',
default: '',
required: true,
placeholder: 'Research or 12',
description: 'Target group by name or numeric ID',
displayOptions: { show: showFor('group', ['addTab', 'move']) },
},
{
displayName: 'Direction',
name: 'direction',
type: 'options',
default: 'forward',
options: [
{ name: 'Forward', value: 'forward' },
{ name: 'Backward', value: 'backward' },
],
displayOptions: { show: showFor('group', ['move']) },
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name for the group/window/session. Required for all but Export (which dumps the active session when empty).',
displayOptions: {
show: {
resource: ['group', 'window', 'session'],
operation: ['create', 'rename', 'save', 'load', 'remove', 'export'],
},
},
},
{
displayName: 'Session A',
name: 'nameA',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('session', ['diff']) },
},
{
displayName: 'Session B',
name: 'nameB',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('session', ['diff']) },
},
{
displayName: 'Enabled',
name: 'enabled',
type: 'boolean',
default: true,
description: 'Whether to turn session auto-save on',
displayOptions: { show: showFor('session', ['autoSave']) },
},
{
displayName: 'Storage Key',
name: 'key',
type: 'string',
default: '',
description: 'Storage key. Required for Set; on Get, omit to dump all keys.',
displayOptions: { show: showFor('storage', ['get', 'set']) },
},
{
displayName: 'Storage Value',
name: 'value',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('storage', ['set']) },
},
{
displayName: 'Storage Type',
name: 'storeType',
type: 'options',
default: 'local',
options: [
{ name: 'localStorage', value: 'local' },
{ name: 'sessionStorage', value: 'session' },
],
displayOptions: { show: showFor('storage', ['get', 'set']) },
},
{
displayName: 'Command',
@@ -332,6 +727,8 @@ function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions
rejectUnauthorized: !creds.allowUnauthorizedCerts,
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
route: creds.browser ? String(creds.browser) : null,
serverIdentity: creds.serverIdentity ? String(creds.serverIdentity) : null,
allowUnknownServerIdentity: Boolean(creds.allowUnknownServerIdentity),
};
}
@@ -357,25 +754,116 @@ function collectParams(
}
return { command: get('command'), args };
}
// --- Tabs -------------------------------------------------------------
case 'tab:open':
return { url: get('url'), focus: get('focus', false) };
case 'tab:navigateTo':
return { tabId: get('tabId', 0), url: get('url') };
case 'tab:close':
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
case 'tab:getHtml':
case 'tab:query':
return { search: get('search', '') };
case 'tab:filter':
case 'tab:count':
return { pattern: get('pattern', '') };
case 'tab:activeInWindow':
return { windowId: get('windowId', 0) };
case 'tab:activate':
return { tabId: get('tabId', 0) };
case 'tab:move':
return { tabId: get('tabId', 0), windowId: get('windowId', 0), index: get('index', '') };
case 'tab:get':
case 'tab:getHtml':
case 'tab:reload':
case 'tab:hardReload':
case 'tab:back':
case 'tab:forward':
case 'tab:mute':
case 'tab:unmute':
case 'tab:pin':
case 'tab:unpin':
return { tabId: get('tabId', 0) };
case 'tab:dedupe':
case 'tab:mergeWindows':
return { gentleMode: get('gentleMode', 'auto') };
case 'tab:sort':
return { by: get('by', 'domain'), gentleMode: get('gentleMode', 'auto') };
case 'tab:screenshot':
return { tabId: get('tabId', 0), format: get('format', 'png'), quality: get('quality', '') };
// --- DOM --------------------------------------------------------------
case 'dom:query':
case 'dom:text':
case 'dom:exists':
case 'dom:click':
case 'dom:hover':
case 'dom:focus':
case 'dom:check':
case 'dom:uncheck':
case 'dom:clear':
case 'dom:submit':
return { selector: get('selector', '') };
case 'dom:type':
return { selector: get('selector', ''), text: get('text', '') };
case 'dom:attr':
return { selector: get('selector', ''), attr: get('attr', '') };
case 'dom:select':
return { selector: get('selector', ''), value: get('value', '') };
case 'dom:key':
return { selector: get('selector', ''), key: get('key', '') };
case 'dom:scroll':
return { selector: get('selector', ''), x: get('x', ''), y: get('y', '') };
case 'dom:eval':
return { code: get('code', ''), tabId: get('tabId', 0) };
// --- Page / extraction ------------------------------------------------
case 'page:extractText':
case 'page:extractLinks':
case 'page:extractImages':
case 'page:extractHtml':
case 'page:extractMarkdown':
case 'page:extractJson':
return { selector: get('selector', '') };
// --- Groups -----------------------------------------------------------
case 'group:query':
return { search: get('search', '') };
case 'group:tabs':
return { groupId: get('groupId', 0) };
case 'group:close':
return { groupId: get('groupId', 0), gentleMode: get('gentleMode', 'auto') };
case 'group:create':
return { name: get('name', '') };
case 'group:addTab':
return { group: get('group', ''), url: get('url', '') };
case 'group:move':
return { group: get('group', ''), direction: get('direction', 'forward') };
// --- Windows ----------------------------------------------------------
case 'window:open':
return { url: get('url', '') };
case 'window:close':
return { windowId: get('windowId', 0) };
case 'window:rename':
return { windowId: get('windowId', 0), name: get('name', '') };
// --- Sessions ---------------------------------------------------------
case 'session:save':
case 'session:load':
case 'session:remove':
case 'session:export':
return { name: get('name', '') };
case 'session:diff':
return { nameA: get('nameA', ''), nameB: get('nameB', '') };
case 'session:autoSave':
return { enabled: get('enabled', true) };
// --- Storage ----------------------------------------------------------
case 'storage:get':
return { key: get('key', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
case 'storage:set':
return { key: get('key', ''), value: get('value', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
default:
return {};
}
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title>browser-cli icon</title>
<defs>
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -24,6 +24,7 @@ import {
createPrivateKey,
createPublicKey,
createHash,
verify as nodeVerify,
createHmac,
createCipheriv,
createDecipheriv,
@@ -234,6 +235,8 @@ export interface Challenge {
nonce?: string;
min_client_version?: string;
pq_kex?: { alg?: string; public_key?: string };
server_pubkey?: string;
server_sig?: string;
}
export interface AuthPayload {
@@ -247,6 +250,81 @@ function pqPublicKey(challenge: Challenge): string | null {
return null;
}
function base64Url(buffer: Buffer): string {
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function ed25519PublicKeyFromHex(pubkeyHex: string): KeyObject {
if (!/^[0-9a-fA-F]{64}$/.test(pubkeyHex)) throw new Error('server public key must be 32-byte hex');
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: base64Url(Buffer.from(pubkeyHex, 'hex')) }, format: 'jwk' });
}
function signedChallenge(challenge: Challenge): Record<string, unknown> {
const { server_sig: _serverSig, ...rest } = challenge;
return rest as Record<string, unknown>;
}
export function serverFingerprint(pubkeyHex: string): string {
return 'SHA256:' + createHash('sha256').update(Buffer.from(pubkeyHex, 'hex')).digest('base64').replace(/=+$/g, '');
}
export function verifyServerChallengeSignature(challenge: Challenge): boolean {
const pubkey = challenge.server_pubkey;
const sig = challenge.server_sig;
if (!pubkey || !sig) return false;
try {
return nodeVerify(
null,
Buffer.from(canonicalJson(signedChallenge(challenge)), 'utf8'),
ed25519PublicKeyFromHex(pubkey),
Buffer.from(sig, 'hex'),
);
} catch {
return false;
}
}
export function verifyServerIdentity(
challenge: Challenge,
expectedServerIdentity: string | null | undefined,
endpoint: string,
allowUnknown: boolean,
): void {
const pubkey = challenge.server_pubkey;
const expected = (expectedServerIdentity || '').trim();
if (!pubkey) {
if (expected) throw new Error(`server ${endpoint} did not advertise a server identity key`);
return;
}
if (!verifyServerChallengeSignature(challenge)) {
throw new Error(`server ${endpoint} identity signature is invalid`);
}
const seenFingerprint = serverFingerprint(pubkey);
if (!expected) {
if (allowUnknown) return;
throw new Error(
`Unknown browser-cli server identity for ${endpoint} (${seenFingerprint}). ` +
'Set the expected Server Public Key/Fingerprint in the Browser CLI credential.',
);
}
if (expected.startsWith('SHA256:')) {
if (expected !== seenFingerprint) {
throw new Error(`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${expected}, seen ${seenFingerprint}`);
}
return;
}
const normalizedExpected = expected.toLowerCase();
if (normalizedExpected !== pubkey.toLowerCase()) {
throw new Error(
`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${serverFingerprint(normalizedExpected)}, seen ${seenFingerprint}`,
);
}
}
/**
* Build the single framed message a client sends in response to the challenge.
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
@@ -7,6 +7,10 @@
* (see `serveClient.ts`). Every operation maps to one raw extension command;
* what the server returns is the *raw* command result (no SDK-side rendering),
* still subject to the server's --allow-* policy noted per operation.
*
* Command names and argument shapes mirror the Python SDK (browser_cli/sdk/*)
* and the server-side policy in browser_cli/command_security.py. Gating per
* operation is documented in BrowserCli.node.ts next to each operation.
*/
export type CommandParams = Record<string, unknown>;
@@ -51,6 +55,16 @@ export function buildCommand(
// --- Tabs -------------------------------------------------------------
case 'tab:list':
return { command: 'tabs.list', args: {} };
case 'tab:query':
return { command: 'tabs.query', args: { search: str(params, 'search') } };
case 'tab:get':
return { command: 'tabs.status', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:count':
return { command: 'tabs.count', args: compact({ pattern: str(params, 'pattern') }) };
case 'tab:filter':
return { command: 'tabs.filter', args: { pattern: str(params, 'pattern') } };
case 'tab:activeInWindow':
return { command: 'tabs.active_in_window', args: { windowId: numArg(params.windowId) } };
case 'tab:open': {
const focus = Boolean(params.focus);
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
@@ -63,6 +77,50 @@ export function buildCommand(
}
case 'tab:getHtml':
return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:activate':
return { command: 'tabs.active', args: { tabId: numArg(params.tabId) } };
case 'tab:move':
return {
command: 'tabs.move',
args: compact({
tabId: numArg(params.tabId),
windowId: tabIdArg(params.windowId),
index: indexArg(params.index),
}),
};
case 'tab:reload':
return { command: 'navigate.reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:hardReload':
return { command: 'navigate.hard_reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:back':
return { command: 'navigate.back', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:forward':
return { command: 'navigate.forward', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:navigateTo':
return { command: 'navigate.to', args: { tabId: numArg(params.tabId), url: str(params, 'url') } };
case 'tab:mute':
return { command: 'tabs.mute', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:unmute':
return { command: 'tabs.unmute', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:pin':
return { command: 'tabs.pin', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:unpin':
return { command: 'tabs.unpin', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:dedupe':
return { command: 'tabs.dedupe', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:sort':
return { command: 'tabs.sort', args: { by: str(params, 'by') || 'domain', gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:mergeWindows':
return { command: 'tabs.merge_windows', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:screenshot':
return {
command: 'tabs.screenshot',
args: compact({
tabId: tabIdArg(params.tabId),
format: str(params, 'format') || 'png',
quality: indexArg(params.quality),
}),
};
// --- Page / extraction ------------------------------------------------
case 'page:info':
@@ -77,6 +135,8 @@ export function buildCommand(
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractMarkdown':
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractJson':
return { command: 'extract.json', args: { selector: str(params, 'selector') } };
// --- DOM --------------------------------------------------------------
case 'dom:query':
@@ -85,17 +145,118 @@ export function buildCommand(
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
case 'dom:type':
return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } };
case 'dom:attr':
return { command: 'dom.attr', args: { selector: str(params, 'selector'), attr: str(params, 'attr') } };
case 'dom:text':
return { command: 'dom.text', args: { selector: str(params, 'selector') } };
case 'dom:exists':
return { command: 'dom.exists', args: { selector: str(params, 'selector') } };
case 'dom:scroll':
return {
command: 'dom.scroll',
args: compact({ selector: str(params, 'selector'), x: indexArg(params.x), y: indexArg(params.y) }),
};
case 'dom:select':
return { command: 'dom.select', args: { selector: str(params, 'selector'), value: str(params, 'value') } };
case 'dom:hover':
return { command: 'dom.hover', args: { selector: str(params, 'selector') } };
case 'dom:check':
return { command: 'dom.check', args: { selector: str(params, 'selector') } };
case 'dom:uncheck':
return { command: 'dom.uncheck', args: { selector: str(params, 'selector') } };
case 'dom:clear':
return { command: 'dom.clear', args: { selector: str(params, 'selector') } };
case 'dom:focus':
return { command: 'dom.focus', args: { selector: str(params, 'selector') } };
case 'dom:submit':
return { command: 'dom.submit', args: { selector: str(params, 'selector') } };
case 'dom:key':
return { command: 'dom.key', args: compact({ key: str(params, 'key'), selector: str(params, 'selector') }) };
case 'dom:eval':
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
// --- Groups -----------------------------------------------------------
case 'group:list':
return { command: 'group.list', args: {} };
case 'group:query':
return { command: 'group.query', args: { search: str(params, 'search') } };
case 'group:tabs':
return { command: 'group.tabs', args: { groupId: numArg(params.groupId) } };
case 'group:count':
return { command: 'group.count', args: {} };
case 'group:create':
return { command: 'group.open', args: { name: str(params, 'name') } };
case 'group:addTab':
return { command: 'group.add_tab', args: compact({ group: str(params, 'group'), url: str(params, 'url') }) };
case 'group:move': {
const direction = str(params, 'direction');
return {
command: 'group.move',
args: { group: str(params, 'group'), forward: direction === 'forward', backward: direction === 'backward' },
};
}
case 'group:close':
return { command: 'group.close', args: { groupId: numArg(params.groupId), gentleMode: str(params, 'gentleMode') || 'auto' } };
// --- Windows ----------------------------------------------------------
case 'window:list':
return { command: 'windows.list', args: {} };
case 'window:open':
return { command: 'windows.open', args: compact({ url: str(params, 'url') }) };
case 'window:close':
return { command: 'windows.close', args: { windowId: numArg(params.windowId) } };
case 'window:rename':
return { command: 'windows.rename', args: { windowId: numArg(params.windowId), name: str(params, 'name') } };
// --- Sessions ---------------------------------------------------------
case 'session:list':
return { command: 'session.list', args: {} };
case 'session:save':
return { command: 'session.save', args: { name: str(params, 'name') } };
case 'session:load':
return { command: 'session.load', args: { name: str(params, 'name') } };
case 'session:remove':
return { command: 'session.remove', args: { name: str(params, 'name') } };
case 'session:export':
return { command: 'session.export', args: compact({ name: str(params, 'name') }) };
case 'session:diff':
return { command: 'session.diff', args: { nameA: str(params, 'nameA'), nameB: str(params, 'nameB') } };
case 'session:autoSave':
return { command: 'session.auto_save', args: { enabled: Boolean(params.enabled) } };
// --- Storage ----------------------------------------------------------
case 'storage:get':
return {
command: 'storage.get',
args: compact({ key: str(params, 'key'), type: str(params, 'storeType') || 'local', tabId: tabIdArg(params.tabId) }),
};
case 'storage:set':
return {
command: 'storage.set',
args: compact({
key: str(params, 'key'),
value: str(params, 'value'),
type: str(params, 'storeType') || 'local',
tabId: tabIdArg(params.tabId),
}),
};
// --- Performance ------------------------------------------------------
case 'perf:status':
return { command: 'perf.status', args: {} };
// --- Extension --------------------------------------------------------
case 'extension:info':
return { command: 'extension.info', args: {} };
case 'extension:capabilities':
return { command: 'extension.capabilities', args: {} };
case 'extension:reload':
return { command: 'extension.reload', args: {} };
// --- Clients ----------------------------------------------------------
case 'client:list':
return { command: 'clients.list', args: {} };
// --- Gateway: serve has no health route, so ping with a safe command --
case 'gateway:health':
return { command: 'tabs.list', args: {} };
default:
throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`);
}
@@ -108,6 +269,19 @@ function tabIdArg(value: unknown): number | undefined {
return Number.isFinite(n) && n > 0 ? n : undefined;
}
/** A required numeric arg; non-finite values fall through as 0. */
function numArg(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/** An optional numeric arg that keeps 0 (a meaningful index/coordinate). */
function indexArg(value: unknown): number | undefined {
if (value === undefined || value === null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
/** Accept an array, a JSON array string, or a comma/space separated list. */
export function parseTabIds(value: unknown): number[] {
if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite);
@@ -10,12 +10,12 @@ import { connect as tlsConnect } from 'node:tls';
import type { Socket } from 'node:net';
import { randomUUID } from 'node:crypto';
import { buildAuthPayload, decodeResponse, frame, type Challenge } from './protocol';
import { buildAuthPayload, decodeResponse, frame, verifyServerIdentity, type Challenge } from './protocol';
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
* client implements. */
const CLIENT_VERSION = '0.15.4';
const CLIENT_VERSION = '0.16.0';
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
@@ -33,6 +33,10 @@ export interface ServeConnectOptions {
privateKeyPem?: string | null;
/** Optional `_route` target for a multi-browser serve. */
route?: string | null;
/** Expected server identity: raw Ed25519 public key hex or SHA256 fingerprint. */
serverIdentity?: string | null;
/** Allow unknown server identities (TOFU disabled). Intended for loopback/dev only. */
allowUnknownServerIdentity?: boolean;
timeoutMs?: number;
}
@@ -121,6 +125,12 @@ export async function sendServeCommand(
const run = async () => {
const challengeRaw = await reader.next();
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
verifyServerIdentity(
challenge,
opts.serverIdentity,
`${opts.host}:${opts.port}`,
Boolean(opts.allowUnknownServerIdentity),
);
const baseMsg: Record<string, unknown> = {
id: randomUUID(),
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "n8n-nodes-browser-cli",
"version": "0.2.4",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "n8n-nodes-browser-cli",
"version": "0.2.4",
"version": "0.3.1",
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@noble/post-quantum": "^0.6.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "n8n-nodes-browser-cli",
"version": "0.2.4",
"version": "0.3.1",
"description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)",
"keywords": [
"n8n-community-node-package",
@@ -11,7 +11,10 @@ import {
pqDecrypt,
pqEncrypt,
pqTransportKey,
serverFingerprint,
signAuth,
verifyServerChallengeSignature,
verifyServerIdentity,
} from '../nodes/BrowserCli/protocol';
// Known-answer vectors produced by the real Python implementation
@@ -49,6 +52,19 @@ const DECRYPT_ENV = {
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
};
const DECRYPT_PLAIN = 'hello world payload';
const SERVER_PUB_HEX = '982c13bda72ef7b2bf4a8cd9756e4f283faaf4a34f9dec8e6c65585b64d9a902';
const SERVER_SIG =
'fa6897bb7f00f711ee8af151384eda736a503008f8b8e11b972cfea46a0366d5' +
'3127c2f1e9ba01e72805b267d023c552756645ae7d95fd00fa277ed20a43ee09';
const SERVER_FP = 'SHA256:wsYDqD4OnF/Sfvr3RKvVCOW8ET802H2qHSvWfnQwQrs';
const SERVER_CHALLENGE = {
type: 'challenge',
nonce: NONCE_HEX,
server_version: '0.16.4',
min_client_version: '0.9.0',
server_pubkey: SERVER_PUB_HEX,
server_sig: SERVER_SIG,
};
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
assert.equal(canonicalJson(MSG), CANON);
@@ -139,3 +155,26 @@ test('decodeResponse parses plain JSON and decrypts PQ envelopes', () => {
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
});
test('server identity fingerprint and signature match Python challenge signing', () => {
assert.equal(serverFingerprint(SERVER_PUB_HEX), SERVER_FP);
assert.equal(verifyServerChallengeSignature(SERVER_CHALLENGE), true);
assert.equal(verifyServerChallengeSignature({ ...SERVER_CHALLENGE, nonce: '22'.repeat(32) }), false);
});
test('verifyServerIdentity accepts pinned pubkey or fingerprint', () => {
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_PUB_HEX, 'browser-host.example:8765', false));
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_FP, 'browser-host.example:8765', false));
});
test('verifyServerIdentity rejects unknown and changed identities', () => {
assert.throws(
() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', false),
/Unknown browser-cli server identity/,
);
assert.throws(
() => verifyServerIdentity(SERVER_CHALLENGE, '00'.repeat(32), 'browser-host.example:8765', false),
/REMOTE SERVER IDENTITY CHANGED/,
);
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', true));
});
+120 -4
View File
@@ -11,10 +11,6 @@ test('client:list maps to clients.list', () => {
assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} });
});
test('gateway:health pings with tabs.list (serve has no health route)', () => {
assert.deepEqual(buildCommand('gateway', 'health', {}), { command: 'tabs.list', args: {} });
});
test('tab:open sends navigate.open with background derived from focus', () => {
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
assert.deepEqual(bg, {
@@ -60,8 +56,128 @@ test('command:execute passes command and args through', () => {
});
});
test('tab read ops map to safe commands', () => {
assert.deepEqual(buildCommand('tab', 'query', { search: 'docs' }), { command: 'tabs.query', args: { search: 'docs' } });
assert.deepEqual(buildCommand('tab', 'filter', { pattern: '*.dev/*' }), { command: 'tabs.filter', args: { pattern: '*.dev/*' } });
assert.deepEqual(buildCommand('tab', 'count', { pattern: '' }).args, {}, 'empty pattern is dropped');
assert.deepEqual(buildCommand('tab', 'get', { tabId: 0 }).args, {}, 'tabId 0 means active tab');
assert.deepEqual(buildCommand('tab', 'get', { tabId: 5 }), { command: 'tabs.status', args: { tabId: 5 } });
assert.deepEqual(buildCommand('tab', 'activeInWindow', { windowId: 3 }), {
command: 'tabs.active_in_window',
args: { windowId: 3 },
});
});
test('tab control ops map to navigate/tabs commands', () => {
assert.deepEqual(buildCommand('tab', 'activate', { tabId: 7 }), { command: 'tabs.active', args: { tabId: 7 } });
assert.deepEqual(buildCommand('tab', 'navigateTo', { tabId: 7, url: 'https://x.dev' }), {
command: 'navigate.to',
args: { tabId: 7, url: 'https://x.dev' },
});
assert.deepEqual(buildCommand('tab', 'reload', { tabId: 0 }), { command: 'navigate.reload', args: {} });
assert.deepEqual(buildCommand('tab', 'back', { tabId: 0 }).command, 'navigate.back');
assert.deepEqual(buildCommand('tab', 'mute', { tabId: 2 }), { command: 'tabs.mute', args: { tabId: 2 } });
assert.deepEqual(buildCommand('tab', 'pin', { tabId: 0 }), { command: 'tabs.pin', args: {} });
});
test('tab move keeps index 0 but drops active tabId fallback for window', () => {
assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 0, index: 0 }), {
command: 'tabs.move',
args: { tabId: 4, index: 0 },
});
assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 9, index: '' }).args, { tabId: 4, windowId: 9 });
});
test('tab rearrange ops default gentleMode and sort key', () => {
assert.deepEqual(buildCommand('tab', 'dedupe', {}), { command: 'tabs.dedupe', args: { gentleMode: 'auto' } });
assert.deepEqual(buildCommand('tab', 'sort', { by: 'title' }), {
command: 'tabs.sort',
args: { by: 'title', gentleMode: 'auto' },
});
assert.deepEqual(buildCommand('tab', 'mergeWindows', {}).command, 'tabs.merge_windows');
});
test('tab screenshot includes quality only when set', () => {
assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 0, format: 'png', quality: '' }).args, { format: 'png' });
assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 1, format: 'jpeg', quality: 80 }).args, {
tabId: 1,
format: 'jpeg',
quality: 80,
});
});
test('dom interaction ops map to dom.* commands', () => {
assert.deepEqual(buildCommand('dom', 'attr', { selector: 'a', attr: 'href' }), {
command: 'dom.attr',
args: { selector: 'a', attr: 'href' },
});
assert.deepEqual(buildCommand('dom', 'select', { selector: '#s', value: 'v' }), {
command: 'dom.select',
args: { selector: '#s', value: 'v' },
});
assert.deepEqual(buildCommand('dom', 'key', { key: 'Enter', selector: '' }).args, { key: 'Enter' });
assert.deepEqual(buildCommand('dom', 'scroll', { selector: '', x: '', y: 500 }).args, { y: 500 });
assert.deepEqual(buildCommand('dom', 'exists', { selector: '#x' }), { command: 'dom.exists', args: { selector: '#x' } });
});
test('page extractJson sends selector', () => {
assert.deepEqual(buildCommand('page', 'extractJson', { selector: 'script' }), {
command: 'extract.json',
args: { selector: 'script' },
});
});
test('group ops map to group.* commands', () => {
assert.deepEqual(buildCommand('group', 'create', { name: 'Research' }), { command: 'group.open', args: { name: 'Research' } });
assert.deepEqual(buildCommand('group', 'tabs', { groupId: 3 }), { command: 'group.tabs', args: { groupId: 3 } });
assert.deepEqual(buildCommand('group', 'addTab', { group: 'Research', url: '' }).args, { group: 'Research' });
assert.deepEqual(buildCommand('group', 'move', { group: '5', direction: 'backward' }), {
command: 'group.move',
args: { group: '5', forward: false, backward: true },
});
assert.deepEqual(buildCommand('group', 'close', { groupId: 2, gentleMode: 'off' }).args, { groupId: 2, gentleMode: 'off' });
});
test('window ops map to windows.* commands', () => {
assert.deepEqual(buildCommand('window', 'open', { url: '' }), { command: 'windows.open', args: {} });
assert.deepEqual(buildCommand('window', 'rename', { windowId: 1, name: 'Work' }), {
command: 'windows.rename',
args: { windowId: 1, name: 'Work' },
});
});
test('session ops map to session.* commands', () => {
assert.deepEqual(buildCommand('session', 'save', { name: 'morning' }), { command: 'session.save', args: { name: 'morning' } });
assert.deepEqual(buildCommand('session', 'export', { name: '' }), { command: 'session.export', args: {} });
assert.deepEqual(buildCommand('session', 'diff', { nameA: 'a', nameB: 'b' }).args, { nameA: 'a', nameB: 'b' });
assert.deepEqual(buildCommand('session', 'autoSave', { enabled: false }), {
command: 'session.auto_save',
args: { enabled: false },
});
});
test('storage ops default to local and drop active tabId', () => {
assert.deepEqual(buildCommand('storage', 'get', { key: 'token', storeType: 'local', tabId: 0 }), {
command: 'storage.get',
args: { key: 'token', type: 'local' },
});
assert.deepEqual(buildCommand('storage', 'set', { key: 'k', value: 'v', storeType: 'session', tabId: 4 }), {
command: 'storage.set',
args: { key: 'k', value: 'v', type: 'session', tabId: 4 },
});
assert.deepEqual(buildCommand('storage', 'get', { key: '', storeType: 'local', tabId: 0 }).args, { type: 'local' });
});
test('perf and extension ops map to safe/control commands', () => {
assert.deepEqual(buildCommand('perf', 'status', {}), { command: 'perf.status', args: {} });
assert.deepEqual(buildCommand('extension', 'capabilities', {}), { command: 'extension.capabilities', args: {} });
assert.deepEqual(buildCommand('extension', 'reload', {}), { command: 'extension.reload', args: {} });
});
test('unknown operation throws', () => {
assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/);
assert.throws(() => buildCommand('connection', 'health', {}), /Unsupported operation/);
assert.throws(() => buildCommand('gateway', 'health', {}), /Unsupported operation/);
});
test('parseTabIds accepts array, json, and delimited strings', () => {
+5 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "real-browser-cli"
version = "0.16.3"
version = "0.16.6"
description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md"
license = { file = "LICENSE" }
@@ -22,9 +22,13 @@ Issues = "https://git.yiprawr.dev/Automatisation/browser-cli/issues"
[project.optional-dependencies]
# Better/faster remote response compression than the stdlib zlib/gzip fallback.
fast = ["zstandard>=0.22"]
mcp = [
"mcp>=2,<3",
]
[project.scripts]
browser-cli = "browser_cli.cli:main"
browser-cli-mcp = "browser_cli.mcp.server:main"
[dependency-groups]
dev = [
+12
View File
@@ -12,6 +12,18 @@ from browser_cli.remote import pool as _remote_pool
TEST_BROWSER_PROFILE = "testing"
@pytest.fixture(autouse=True)
def _isolate_remote_registry(monkeypatch, tmp_path):
"""Point the remembered-remote registry at an empty throwaway file.
Endpoint resolution consults remembered remotes, so without this a developer
who has remembered ``host:8765`` sees different results than CI for the same
code. Tests that need remembered entries still monkeypatch the path themselves.
"""
monkeypatch.setattr(
"browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "empty-remotes.json"
)
@pytest.fixture(autouse=True)
def _clear_remote_pool():
"""Close any pooled remote connections between tests so a connection opened
+34 -11
View File
@@ -294,29 +294,52 @@ 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, key=None):
assert command == "clients.list"
assert profile is None
assert remote == "127.0.0.1:8765"
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
target = BrowserTarget(
profile="work",
display_name="127.0.0.1:work",
socket_path="",
remote="127.0.0.1:8765",
browser_name="Chrome",
display_group="127.0.0.1",
version="1",
extension_version="2.3.4",
)
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
), patch("browser_cli.client.core.send_command", side_effect=fake_send_command) as send_command:
), patch("browser_cli.client.core.remote_browser_targets", return_value=[target]), patch(
"browser_cli.client.core.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()
assert "remote" in result.output
send_command.assert_not_called()
assert "work" in result.output
assert "127.0.0.1" not in result.output
assert "Chrome" in result.output
assert "2.3.4" in result.output
def test_clients_remote_respects_global_browser_route():
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.client.core.send_command", return_value=[]) as send_command:
target = BrowserTarget(
profile="work",
display_name="127.0.0.1:work",
socket_path="",
remote="127.0.0.1:8765",
browser_name="Chrome",
display_group="127.0.0.1",
version="1",
extension_version="2.3.4",
)
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.client.core.remote_browser_targets", return_value=[target]
), patch("browser_cli.client.core.send_command") as send_command:
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", key=None)
assert result.exit_code == 0
send_command.assert_not_called()
assert "work" in result.output
assert "127.0.0.1" not in result.output
def test_clients_browser_alias_resolves_to_remote():
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
+66 -15
View File
@@ -356,7 +356,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
assert targets[0].display_group == "browser-host.example"
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("browser-host.example") is True
assert _looks_like_domain("sub.domain.org") is True
assert _looks_like_domain("localhost") is False
@@ -365,17 +365,17 @@ def test_looks_like_domain():
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("browser-host.example:443") == "browser-host.example"
assert _normalize_endpoint("browser-host.example") == "browser-host.example"
assert _normalize_endpoint("203.0.113.1:443") == "203.0.113.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"
assert _normalize_endpoint("browser-host.example:8765") == "browser-host.example: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("browser-host.example") == "browser-host.example:443"
assert _resolve_connect_endpoint("browser-host.example:443") == "browser-host.example:443"
assert _resolve_connect_endpoint("browser-host.example:8765") == "browser-host.example:8765"
assert _resolve_connect_endpoint("host:8765") == "host:8765"
def test_resolve_connect_endpoint_raises_for_bare_non_domain():
@@ -399,9 +399,9 @@ def test_send_command_normalizes_domain_port_443(monkeypatch):
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
result = send_command("tabs.list", remote="browsercli.yiprawr.dev:443")
result = send_command("tabs.list", remote="browser-host.example:443")
assert result == "ok"
assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # stored/routed without port
assert sent_to["endpoint"] == "browser-host.example" # stored/routed without port
def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
"""--remote domain (no port) is treated as :443."""
@@ -420,14 +420,14 @@ def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
result = send_command("tabs.list", remote="browsercli.yiprawr.dev")
result = send_command("tabs.list", remote="browser-host.example")
assert result == "ok"
assert sent_to["endpoint"] == "browsercli.yiprawr.dev"
assert sent_to["endpoint"] == "browser-host.example"
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"
endpoint = "browser-host.example"
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
@@ -440,13 +440,13 @@ def test_domain_display_name_omits_port(monkeypatch, tmp_path):
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
assert targets[0].display_name == "browser-host.example: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
endpoint = "browser-host.example:443" # old format
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
@@ -459,7 +459,7 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
assert targets[0].display_name == "browser-host.example:automatisation"
def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
"""--key is a one-shot override; use `browser-cli remote trust` to remember it."""
@@ -649,6 +649,57 @@ def test_collect_browser_clients_uses_cached_target_version(monkeypatch, tmp_pat
"extensionVersion": "0.15.6",
}]
def test_collect_browser_clients_with_explicit_remote_lists_all_targets(monkeypatch, tmp_path):
"""`browser-cli --remote host clients` should list all profiles, not auto-route and fail as ambiguous."""
from browser_cli.client import collect_browser_clients
import browser_cli.client.core as core
targets = [
BrowserTarget(
profile="main",
display_name="browser-host.example:main",
socket_path="",
remote="browser-host.example:8765",
browser_name="Chrome",
display_group="browser-host.example",
version="149.0.0.0",
extension_version="0.16.4",
),
BrowserTarget(
profile="work",
display_name="browser-host.example:work",
socket_path="",
remote="browser-host.example:8765",
browser_name="Firefox",
display_group="browser-host.example",
version="151.0",
extension_version="0.16.4",
),
]
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
monkeypatch.setattr(core, "send_command", lambda *a, **k: pytest.fail("clients.list must not auto-route for cached targets"))
rows = collect_browser_clients(remote="browser-host.example:8765", registry_path=tmp_path / "missing-registry.json")
assert [row["profile"] for row in rows] == ["main", "work"]
assert [row.get("profileGroup") for row in rows] == [None, None]
assert [row["name"] for row in rows] == ["Chrome", "Firefox"]
def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target(monkeypatch, tmp_path):
from browser_cli.client import collect_browser_clients
import browser_cli.client.core as core
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", version="1"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", version="1"),
]
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
rows = collect_browser_clients(remote="browser-host.example:8765", browser_alias="work", registry_path=tmp_path / "missing-registry.json")
assert [row["profile"] for row in rows] == ["work"]
assert rows[0].get("profileGroup") is None
def test_collect_browser_clients_falls_back_when_version_unknown(monkeypatch, tmp_path):
"""An older remote (no advertised version) still triggers a clients.list query."""
from browser_cli.client import collect_browser_clients
+48
View File
@@ -0,0 +1,48 @@
"""Compat shim framework.
The registries are empty today (no legacy-client shim has been needed since the
first public release, 0.14.1), so every adapter must be a verbatim pass-through
regardless of client version. These tests lock that in and exercise the
empty-registry short-circuit so the seam can't silently start mutating traffic.
"""
import browser_cli.compat as compat
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
def test_registries_are_empty():
assert compat.commands._COMPAT == []
assert compat.auth._AUTH_COMPAT == []
def test_adapt_auth_is_passthrough_for_any_version():
msg = {"id": "1", "command": "tabs.list", "pubkey": "ABCdef", "args": {"x": 1}}
for version in ("0.9.0", "0.14.1", "0.16.4", "99.0.0"):
out = adapt_auth(msg, version)
assert out == msg
# pubkey casing is NOT normalized anymore (the old <0.9.3 shim is gone)
assert out["pubkey"] == "ABCdef"
def test_adapt_request_is_passthrough():
msg = {"command": "tabs.query", "args": {"search": "docs"}}
assert adapt_request(msg, "0.9.0") == msg
assert adapt_request(msg, "0.16.4") == msg
def test_adapt_response_is_passthrough():
resp = b'{"id":"1","success":true,"data":[]}'
assert adapt_response(resp, "tabs.list", "0.9.0") == resp
assert adapt_response(resp, "tabs.list", "0.16.4") == resp
def test_empty_guard_skips_version_parsing(monkeypatch):
"""With empty registries the adapters return before parse_version runs."""
called = False
def _boom(_v):
nonlocal called
called = True
raise AssertionError("parse_version should not be called on an empty registry")
monkeypatch.setattr(compat.auth, "parse_version", _boom)
monkeypatch.setattr(compat.commands, "parse_version", _boom)
assert adapt_auth({"a": 1}, "0.9.0") == {"a": 1}
assert adapt_request({"a": 1}, "0.9.0") == {"a": 1}
assert adapt_response(b"x", "cmd", "0.9.0") == b"x"
assert called is False
+64
View File
@@ -0,0 +1,64 @@
import json
import pytest
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge, verify_challenge_signature
from browser_cli.errors import BrowserNotConnected
from browser_cli.remote import known_hosts
def _challenge(tmp_path):
key = load_or_create_server_identity(tmp_path / "server.pem")
msg = {
"type": "challenge",
"nonce": "00" * 32,
"server_version": "0.16.4",
"min_client_version": "0.9.0",
"server_pubkey": public_key_hex(key),
}
msg["server_sig"] = sign_challenge(msg, key)
return msg
def test_challenge_signature_verifies(tmp_path):
challenge = _challenge(tmp_path)
assert verify_challenge_signature(challenge) is True
challenge["nonce"] = "11" * 32
assert verify_challenge_signature(challenge) is False
def test_known_host_mismatch_is_rejected(monkeypatch, tmp_path):
path = tmp_path / "known_hosts.json"
challenge = _challenge(tmp_path)
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"browser-host.example": "00" * 32}), encoding="utf-8")
with pytest.raises(BrowserNotConnected, match="REMOTE SERVER IDENTITY CHANGED"):
known_hosts.verify_known_host("browser-host.example", challenge)
def test_unknown_non_interactive_host_is_rejected(monkeypatch, tmp_path):
path = tmp_path / "known_hosts.json"
challenge = _challenge(tmp_path)
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
with pytest.raises(BrowserNotConnected, match="Unknown remote server identity"):
known_hosts.verify_known_host("browser-host.example", challenge)
def test_loopback_unknown_host_is_allowed(monkeypatch, tmp_path):
path = tmp_path / "known_hosts.json"
challenge = _challenge(tmp_path)
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
known_hosts.verify_known_host("127.0.0.1:8765", challenge)
assert not path.exists()
def test_save_and_remove_known_host(tmp_path):
path = tmp_path / "known_hosts.json"
known_hosts.save_known_host("browser-host.example:443", "11" * 32, path)
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": "11" * 32}
assert known_hosts.remove_known_host("browser-host.example", path) is True
assert known_hosts.load_known_hosts(path) == {}
+241
View File
@@ -0,0 +1,241 @@
"""Tests for the optional stateless MCP adapter."""
from __future__ import annotations
import base64
from types import SimpleNamespace
import pytest
pytest.importorskip("mcp")
from mcp import Client
from mcp.types import ImageContent
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.naming import resolve_tool_prefix
from browser_cli.mcp.server import _screenshot_bytes, create_server, main
from browser_cli.models import Tab
class FakeClient:
instances: list["FakeClient"] = []
def __init__(self, browser=None, remote=None, key=None):
self.target = (browser, remote, key)
self.calls: list[tuple] = []
self.tabs = SimpleNamespace(
list=self.tabs_list,
open=self.tabs_open,
close=self.tabs_close,
active=self.tabs_active,
status=self.tabs_status,
screenshot=self.tabs_screenshot,
)
self.nav = SimpleNamespace(to=self.navigate_to)
self.page = SimpleNamespace(info=self.page_info)
self.extract = SimpleNamespace(text=self.extract_text, markdown=self.extract_markdown)
self.dom = SimpleNamespace(query=self.dom_query, click=self.dom_click, type=self.dom_type)
self.instances.append(self)
def tabs_list(self):
return [{"id": 7, "title": "Example", "url": "https://example.com"}]
def tabs_open(self, url, **kwargs):
self.calls.append(("open", url, kwargs))
return {"id": 8, "title": "Opened", "url": url}
def tabs_close(self, tab_id):
self.calls.append(("close", tab_id))
return 1
def tabs_active(self):
self.calls.append(("active",))
return SimpleNamespace(id=7)
def tabs_status(self, tab_id):
self.calls.append(("status", tab_id))
return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
def tabs_screenshot(self, tab_id, **kwargs):
self.calls.append(("screenshot", tab_id, kwargs))
return "data:image/png;base64," + base64.b64encode(b"png-data").decode()
def navigate_to(self, tab_id, url):
self.calls.append(("navigate", tab_id, url))
def page_info(self):
return {"title": "Example", "url": "https://example.com"}
def extract_text(self):
return "Page text"
def extract_markdown(self, selector=None):
return f"# Page {selector or ''}".rstrip()
def dom_query(self, selector):
return [{"tag": "button", "selector": selector}]
def dom_click(self, selector):
self.calls.append(("click", selector))
def dom_type(self, selector, text):
self.calls.append(("type", selector, text))
@pytest.fixture(autouse=True)
def clear_instances():
FakeClient.instances.clear()
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def client():
server = create_server(client_factory=FakeClient)
async with Client(server, raise_exceptions=True) as connected:
yield connected
@pytest.mark.anyio
async def test_each_tool_call_constructs_a_fresh_targeted_client(client, monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
first = await client.call_tool("browser_tabs_list", {
"browser": "work",
"remote": "browser-host.example:443",
"key": "agent",
})
second = await client.call_tool("browser_page_info", {})
assert first.structured_content == {
"result": [{"id": 7, "title": "Example", "url": "https://example.com"}]
}
assert second.structured_content == {
"title": "Example",
"url": "https://example.com",
}
assert len(FakeClient.instances) == 2
assert FakeClient.instances[0].target == ("work", "browser-host.example:443", "agent")
assert FakeClient.instances[1].target == (None, None, None)
@pytest.mark.anyio
async def test_environment_pins_all_calls_to_one_browser(client, monkeypatch):
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
await client.call_tool("browser_tabs_list", {})
assert FakeClient.instances[-1].target == ("testing", None, None)
def test_tool_prefix_defaults_to_browser_and_is_configurable():
assert resolve_tool_prefix({}) == "browser_"
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": ""}) == ""
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web"}) == "web_"
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web_"}) == "web_"
with pytest.raises(ValueError):
resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "9-bad prefix"})
@pytest.mark.anyio
async def test_hosts_that_namespace_tools_can_drop_the_builtin_prefix():
server = create_server(client_factory=FakeClient, tool_prefix="")
async with Client(server, raise_exceptions=True) as connected:
names = {tool.name for tool in (await connected.list_tools()).tools}
assert "tabs_list" in names
assert not any(name.startswith("browser_") for name in names)
@pytest.mark.anyio
async def test_explicit_target_overrides_environment_pin(client, monkeypatch):
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
await client.call_tool("browser_tabs_list", {"browser": "main"})
assert FakeClient.instances[-1].target == ("main", None, None)
@pytest.mark.anyio
async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
opened = await client.call_tool("browser_tabs_open", {
"url": "https://example.com",
"wait": True,
"focus": True,
})
navigated = await client.call_tool("browser_navigate", {
"tab_id": 8,
"url": "https://example.com/next",
})
clicked = await client.call_tool("browser_dom_click", {"selector": "#submit"})
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel"})
assert opened.structured_content == {
"id": 8, "title": "Opened", "url": "https://example.com"
}
assert navigated.structured_content["id"] == 8
assert clicked.structured_content["url"] == "https://example.com"
assert typed.structured_content == {"typed": True}
assert FakeClient.instances[0].calls == [
("open", "https://example.com", {
"wait": True, "timeout": 30.0, "background": False, "focus": True
})
]
assert FakeClient.instances[1].calls == [
("navigate", 8, "https://example.com/next"), ("status", 8)
]
@pytest.mark.anyio
async def test_tab_tools_default_to_the_active_tab(client):
navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"})
closed = await client.call_tool("browser_tabs_close", {})
assert navigated.structured_content["id"] == 7
assert closed.structured_content == {"closed": 1, "tab_id": 7}
assert FakeClient.instances[0].calls == [
("active",), ("navigate", 7, "https://example.com/next"), ("status", 7)
]
assert FakeClient.instances[1].calls == [("active",), ("close", 7)]
@pytest.mark.anyio
async def test_screenshot_returns_image_content(client):
result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"})
assert result.structured_content is None
assert len(result.content) == 1
assert isinstance(result.content[0], ImageContent)
assert result.content[0].data == base64.b64encode(b"png-data").decode()
assert result.content[0].mime_type == "image/png"
def test_screenshot_decoder_rejects_non_data_url():
with pytest.raises(ValueError, match="invalid screenshot"):
_screenshot_bytes("not-an-image")
def test_sdk_dataclass_serialization_does_not_traverse_bound_client():
tab = Tab(id=7, window_id=1, active=True, title="Example")
tab._browser = SimpleNamespace(secret="must not be serialized")
result = structured(tab)
assert result["id"] == 7
assert result["window_id"] == 1
assert "_browser" not in result
def test_http_server_refuses_non_local_bind(monkeypatch):
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: None))
with pytest.raises(SystemExit, match="Refusing to expose"):
main(["--transport", "streamable-http", "--host", "0.0.0.0"])
def test_http_server_enables_stateless_json_transport(monkeypatch):
calls = []
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: calls.append(kwargs)))
main(["--transport", "streamable-http", "--port", "9000", "--path", "/browser"])
assert calls == [{
"transport": "streamable-http",
"host": "127.0.0.1",
"port": 9000,
"streamable_http_path": "/browser",
"stateless_http": True,
"json_response": True,
}]
+59
View File
@@ -0,0 +1,59 @@
import json
from click.testing import CliRunner
from browser_cli.commands.remote import remote_group
from browser_cli.remote import registry as remote_registry
def test_save_remote_persists_endpoint_without_key(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
remote_registry.save_remote("browser-host.example:443")
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": {}}
def test_save_remote_with_key_and_remove(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
remote_registry.save_remote("browser-host.example", "agent")
assert remote_registry.load_remotes() == {"browser-host.example": {"key": "agent"}}
assert remote_registry.remove_remote("browser-host.example:443") is True
assert remote_registry.load_remotes() == {}
def test_resolve_remote_endpoint_prefers_remembered_explicit_port(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
path.write_text(json.dumps({"browser-host.example": {}, "browser-host.example:8765": {}}), encoding="utf-8")
assert remote_registry.resolve_remote_endpoint("browser-host.example") == "browser-host.example:8765"
def test_resolve_remote_endpoint_keeps_bare_domain_without_unique_port_match(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
path.write_text(
json.dumps({"browser-host.example:8765": {}, "browser-host.example:9000": {}}),
encoding="utf-8",
)
assert remote_registry.resolve_remote_endpoint("browser-host.example") == "browser-host.example"
def test_remote_add_list_remove_cli(monkeypatch, tmp_path):
path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
runner = CliRunner()
add_result = runner.invoke(remote_group, ["add", "browser-host.example", "--key", "agent"])
list_result = runner.invoke(remote_group, ["list"])
remove_result = runner.invoke(remote_group, ["remove", "browser-host.example"])
assert add_result.exit_code == 0
assert "Added remote browser-host.example with key agent" in add_result.output
assert list_result.exit_code == 0
assert "browser-host.example" in list_result.output
assert "agent" in list_result.output
assert remove_result.exit_code == 0
assert "Removed browser-host.example" in remove_result.output
assert remote_registry.load_remotes() == {}
-27
View File
@@ -228,33 +228,6 @@ class TestAuthSuccess:
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.targets.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.targets.resolve_socket", _mock_no_browser)
Generated
+964 -263
View File
File diff suppressed because it is too large Load Diff