Compare commits
10
Commits
v0.16.3
...
6352d9994e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6352d9994e
|
||
|
|
541b950519
|
||
|
|
1b32410575
|
||
|
|
581cd73cac
|
||
|
|
bd2a18baba
|
||
|
|
7b4d96845d
|
||
|
|
937c6a1ce0
|
||
|
|
6270d8c956
|
||
|
|
1ae9c33f00
|
||
|
|
b91b29d516
|
@@ -28,8 +28,8 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
browser-cli-client-version:
|
browser-cli-client-version:
|
||||||
- "0.9.3"
|
- "0.15.0"
|
||||||
- "0.9.5"
|
- "0.16.0"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ For better remote-response compression, install the optional `fast` extra:
|
|||||||
uv tool install "real-browser-cli[fast]"
|
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:
|
To upgrade later:
|
||||||
|
|
||||||
```sh
|
```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
|
## 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`.
|
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
|
```sh
|
||||||
browser-cli tabs list # list all open tabs (all windows)
|
browser-cli tabs list # list all open tabs (all windows)
|
||||||
browser-cli tabs count # count all tabs
|
browser-cli tabs count # count all tabs
|
||||||
browser-cli tabs count youtube # count tabs matching URL pattern
|
browser-cli tabs count youtube # count tabs whose URL contains "youtube"
|
||||||
browser-cli tabs filter youtube # list tabs matching URL pattern
|
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 query "pull request" # search tabs by URL or title
|
||||||
|
|
||||||
browser-cli tabs active 1234 # switch browser focus to tab
|
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
|
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
|
### Tab groups
|
||||||
```sh
|
```sh
|
||||||
browser-cli groups list # list all tab groups
|
browser-cli groups list # list all tab groups
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))
|
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
|
||||||
return targets
|
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.
|
"""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
|
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:
|
if target.version is None and target.extension_version is None:
|
||||||
return None
|
return None
|
||||||
return {
|
row = {
|
||||||
"profile": target.display_name,
|
"profile": target.profile if scoped else target.display_name,
|
||||||
"profileGroup": target.display_group,
|
|
||||||
"name": target.browser_name or "",
|
"name": target.browser_name or "",
|
||||||
"version": target.version or "",
|
"version": target.version or "",
|
||||||
"extensionVersion": target.extension_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]:
|
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
|
||||||
rows = []
|
rows = []
|
||||||
@@ -243,6 +247,34 @@ def collect_browser_clients(
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
if remote:
|
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)
|
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||||
for item in result or []:
|
for item in result or []:
|
||||||
row = dict(item)
|
row = dict(item)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from browser_cli import transport
|
from browser_cli import transport
|
||||||
from browser_cli.endpoints import _normalize_endpoint
|
|
||||||
from browser_cli.errors import BrowserNotConnected
|
from browser_cli.errors import BrowserNotConnected
|
||||||
|
from browser_cli.remote.registry import resolve_remote_endpoint
|
||||||
|
|
||||||
def base_message(command: str, args: dict | None) -> dict:
|
def base_message(command: str, args: dict | None) -> dict:
|
||||||
return {"id": str(uuid.uuid4()), "command": command, "args": args or {}}
|
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]:
|
def requested_target(profile: str | None, remote: str | None) -> tuple[str | None, str | None]:
|
||||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
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:
|
def encode_payload(msg: dict) -> bytes:
|
||||||
return json.dumps(msg).encode("utf-8")
|
return json.dumps(msg).encode("utf-8")
|
||||||
|
|||||||
@@ -1,18 +1,53 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
from browser_cli.errors import BrowserNotConnected
|
||||||
|
|
||||||
from browser_cli import BrowserCLI
|
from browser_cli import BrowserCLI
|
||||||
from browser_cli.commands import handle_errors
|
from browser_cli.commands import handle_errors
|
||||||
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
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()
|
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")
|
@click.group("remote")
|
||||||
def remote_group():
|
def remote_group():
|
||||||
"""Manage remembered browser-cli remote endpoints."""
|
"""Manage remembered browser-cli remote endpoints."""
|
||||||
@@ -42,6 +77,17 @@ def remote_status(endpoint, key):
|
|||||||
browser_header="Profile",
|
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")
|
@remote_group.command("trust")
|
||||||
@click.argument("endpoint")
|
@click.argument("endpoint")
|
||||||
@click.argument("key_spec")
|
@click.argument("key_spec")
|
||||||
@@ -50,29 +96,58 @@ def remote_trust(endpoint, key_spec):
|
|||||||
save_remote_key(endpoint, key_spec)
|
save_remote_key(endpoint, key_spec)
|
||||||
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
|
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
|
||||||
|
|
||||||
@remote_group.command("keys")
|
@remote_group.command("list")
|
||||||
def remote_keys():
|
def remote_list():
|
||||||
"""List remembered remote key specs."""
|
"""List remembered remote endpoints."""
|
||||||
remotes = load_remotes()
|
_print_remotes()
|
||||||
if not remotes:
|
|
||||||
console.print("[yellow]No remembered remotes[/yellow]")
|
@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
|
return
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
table.add_column("Endpoint")
|
table.add_column("Endpoint")
|
||||||
table.add_column("Key")
|
table.add_column("Fingerprint")
|
||||||
for endpoint, cfg in sorted(remotes.items()):
|
table.add_column("Public Key")
|
||||||
table.add_row(endpoint, str(cfg.get("key", "")))
|
for endpoint, pubkey in sorted(known.items()):
|
||||||
|
table.add_row(endpoint, fingerprint(pubkey), pubkey)
|
||||||
console.print(table)
|
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")
|
@remote_group.command("revoke")
|
||||||
@click.argument("endpoint")
|
@click.argument("endpoint")
|
||||||
def remote_revoke(endpoint):
|
def remote_revoke(endpoint):
|
||||||
"""Remove remembered key/config for ENDPOINT."""
|
"""Remove remembered key/config for ENDPOINT."""
|
||||||
remotes = load_remotes()
|
_remove_remote(endpoint, verb="Revoked")
|
||||||
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]")
|
|
||||||
|
|||||||
@@ -6,43 +6,23 @@ 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.
|
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 __future__ import annotations
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from browser_cli.version_manager import parse_version
|
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 ──────────────────────────────────────────────────────────────────
|
# ── registry ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
|
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = []
|
||||||
("0.9.3", _auth_0_9_3),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def adapt_auth(msg: dict, client_version: str) -> dict:
|
def adapt_auth(msg: dict, client_version: str) -> dict:
|
||||||
"""Apply all auth normalizers needed to bring msg up to the current format."""
|
"""Apply all auth normalizers needed to bring msg up to the current format."""
|
||||||
|
if not _AUTH_COMPAT:
|
||||||
|
return msg
|
||||||
cv = parse_version(client_version)
|
cv = parse_version(client_version)
|
||||||
for version, fn in _AUTH_COMPAT:
|
for version, fn in _AUTH_COMPAT:
|
||||||
if cv < parse_version(version):
|
if cv < parse_version(version):
|
||||||
|
|||||||
@@ -11,31 +11,34 @@ Add one entry per breaking command-format change:
|
|||||||
Entries must stay in ascending version order.
|
Entries must stay in ascending version order.
|
||||||
adapt_request walks forward (oldest first); adapt_response walks backward.
|
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 __future__ import annotations
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from browser_cli.version_manager import parse_version
|
from browser_cli.version_manager import parse_version
|
||||||
|
|
||||||
|
|
||||||
# ── registry ──────────────────────────────────────────────────────────────────
|
# ── registry ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
|
_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:
|
def adapt_request(msg: dict, client_version: str) -> dict:
|
||||||
"""Upgrade a client message to the current browser command format."""
|
"""Upgrade a client message to the current browser command format."""
|
||||||
|
if not _COMPAT:
|
||||||
|
return msg
|
||||||
cv = parse_version(client_version)
|
cv = parse_version(client_version)
|
||||||
for version, req_fn, _ in _COMPAT:
|
for version, req_fn, _ in _COMPAT:
|
||||||
if cv < parse_version(version) and req_fn is not None:
|
if cv < parse_version(version) and req_fn is not None:
|
||||||
msg = req_fn(msg)
|
msg = req_fn(msg)
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
|
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
|
||||||
"""Downgrade a native-host response to the format the client expects."""
|
"""Downgrade a native-host response to the format the client expects."""
|
||||||
|
if not _COMPAT:
|
||||||
|
return resp
|
||||||
cv = parse_version(client_version)
|
cv = parse_version(client_version)
|
||||||
for version, _, resp_fn in reversed(_COMPAT):
|
for version, _, resp_fn in reversed(_COMPAT):
|
||||||
if cv < parse_version(version) and resp_fn is not None:
|
if cv < parse_version(version) and resp_fn is not None:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -26,6 +26,7 @@ from browser_cli.framing import frame
|
|||||||
# hand back one the server has just timed out and closed.
|
# hand back one the server has just timed out and closed.
|
||||||
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
|
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
|
||||||
_MAX_PER_ENDPOINT = 8
|
_MAX_PER_ENDPOINT = 8
|
||||||
|
_MAX_ENDPOINTS = 64
|
||||||
|
|
||||||
class PooledConnection:
|
class PooledConnection:
|
||||||
__slots__ = ("sock", "secret", "last_used")
|
__slots__ = ("sock", "secret", "last_used")
|
||||||
@@ -56,10 +57,32 @@ def checkout(endpoint: str) -> PooledConnection | None:
|
|||||||
_close(conn.sock) # too old — assume the server has dropped it
|
_close(conn.sock) # too old — assume the server has dropped it
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _prune_endpoints_locked(now: float) -> None:
|
||||||
|
"""Keep the number of endpoint buckets bounded for long-running SDK users."""
|
||||||
|
for endpoint, bucket in list(_POOL.items()):
|
||||||
|
fresh = [conn for conn in bucket if now - conn.last_used <= _MAX_IDLE_SECONDS]
|
||||||
|
if fresh:
|
||||||
|
_POOL[endpoint] = fresh
|
||||||
|
else:
|
||||||
|
for conn in bucket:
|
||||||
|
_close(conn.sock)
|
||||||
|
_POOL.pop(endpoint, None)
|
||||||
|
|
||||||
|
while len(_POOL) >= _MAX_ENDPOINTS:
|
||||||
|
oldest_endpoint, bucket = min(
|
||||||
|
_POOL.items(),
|
||||||
|
key=lambda item: min(conn.last_used for conn in item[1]) if item[1] else 0.0,
|
||||||
|
)
|
||||||
|
for conn in bucket:
|
||||||
|
_close(conn.sock)
|
||||||
|
_POOL.pop(oldest_endpoint, None)
|
||||||
|
|
||||||
def checkin(endpoint: str, conn: PooledConnection) -> None:
|
def checkin(endpoint: str, conn: PooledConnection) -> None:
|
||||||
"""Return a still-healthy connection to the pool for reuse."""
|
"""Return a still-healthy connection to the pool for reuse."""
|
||||||
conn.last_used = time.monotonic()
|
conn.last_used = time.monotonic()
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
|
if endpoint not in _POOL and len(_POOL) >= _MAX_ENDPOINTS:
|
||||||
|
_prune_endpoints_locked(conn.last_used)
|
||||||
bucket = _POOL.setdefault(endpoint, [])
|
bucket = _POOL.setdefault(endpoint, [])
|
||||||
if len(bucket) >= _MAX_PER_ENDPOINT:
|
if len(bucket) >= _MAX_PER_ENDPOINT:
|
||||||
_close(conn.sock)
|
_close(conn.sock)
|
||||||
|
|||||||
@@ -22,24 +22,71 @@ def load_remotes() -> dict[str, dict[str, str]]:
|
|||||||
# Normalize keys so old entries stored as "domain:443" match current lookups.
|
# 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)}
|
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:
|
def is_valid_key_spec(value: str) -> bool:
|
||||||
"""Return True for 'agent', 'agent:<selector>', or a plausible key file path."""
|
"""Return True for 'agent', 'agent:<selector>', or a plausible key file path."""
|
||||||
return value == "agent" or value.startswith("agent:") or (
|
return value == "agent" or value.startswith("agent:") or (
|
||||||
not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
|
not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_remote_key(endpoint: str, key_spec: str) -> None:
|
def save_remote(endpoint: str, key_spec: str | None = None) -> None:
|
||||||
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
|
"""Persist a remote endpoint, optionally with a key spec."""
|
||||||
if not endpoint or not key_spec or not is_valid_key_spec(key_spec):
|
if not endpoint:
|
||||||
return
|
return
|
||||||
|
normalized = _normalize_endpoint(endpoint)
|
||||||
remotes = load_remotes()
|
remotes = load_remotes()
|
||||||
current = remotes.get(endpoint, {})
|
current = remotes.get(normalized, {})
|
||||||
|
if key_spec:
|
||||||
|
if not is_valid_key_spec(key_spec):
|
||||||
|
return
|
||||||
current["key"] = key_spec
|
current["key"] = key_spec
|
||||||
remotes[endpoint] = current
|
remotes[normalized] = current
|
||||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
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)
|
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:
|
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:
|
def key_for_remote(endpoint: str | None) -> str | None:
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from browser_cli.remote.socket import (
|
|||||||
split_endpoint as _split_endpoint,
|
split_endpoint as _split_endpoint,
|
||||||
)
|
)
|
||||||
from browser_cli.remote import pool as _pool
|
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:
|
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.
|
# 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)
|
sock = _connect_socket(endpoint)
|
||||||
try:
|
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")))
|
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||||
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||||
except BaseException:
|
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)
|
reader, writer = await _open_async_connection(endpoint)
|
||||||
try:
|
try:
|
||||||
challenge_raw = await _async_recv_all(reader)
|
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
|
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):
|
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | No
|
|||||||
if pq_keypair is not None:
|
if pq_keypair is not None:
|
||||||
pq_private_key, pq_public_key = pq_keypair
|
pq_private_key, pq_public_key = pq_keypair
|
||||||
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
|
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
|
return nonce, pq_private_key, challenge_msg
|
||||||
|
|||||||
@@ -70,19 +70,48 @@ class RateLimiter:
|
|||||||
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
|
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
|
||||||
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
|
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
|
||||||
Thread-safe so it can be shared across all connections of one serve process.
|
Thread-safe so it can be shared across all connections of one serve process.
|
||||||
|
|
||||||
|
The bucket table is capped. Without that bound, a long-running public server
|
||||||
|
could retain one entry per ever-seen identity/IP forever; GC cannot reclaim
|
||||||
|
those entries because the limiter still references them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, rate: float, burst: float | None = None) -> None:
|
def __init__(self, rate: float, burst: float | None = None, max_buckets: int = 4096) -> None:
|
||||||
self.rate = float(rate)
|
self.rate = float(rate)
|
||||||
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
|
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
|
||||||
|
self.max_buckets = max(1, int(max_buckets))
|
||||||
self._buckets: dict[str, tuple[float, float]] = {}
|
self._buckets: dict[str, tuple[float, float]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune_locked(self, now: float) -> None:
|
||||||
|
"""Drop idle/full buckets, then oldest buckets, until the table is bounded."""
|
||||||
|
if len(self._buckets) < self.max_buckets or self.rate <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Once a bucket has fully refilled, keeping it around carries no useful
|
||||||
|
# throttling state. Use at least 60s so normal active identities are not
|
||||||
|
# churned out aggressively on high-rate configs.
|
||||||
|
idle_seconds = max(60.0, (self.capacity / self.rate) * 2)
|
||||||
|
full_epsilon = 1e-9
|
||||||
|
for bucket_key, (tokens, last) in list(self._buckets.items()):
|
||||||
|
refilled = min(self.capacity, tokens + (now - last) * self.rate)
|
||||||
|
if refilled >= self.capacity - full_epsilon and now - last >= idle_seconds:
|
||||||
|
self._buckets.pop(bucket_key, None)
|
||||||
|
|
||||||
|
# If an attacker keeps creating fresh identities faster than they go idle,
|
||||||
|
# still keep memory bounded. Evict the oldest identity state; that may reset
|
||||||
|
# throttling for that identity, but bounded memory is more important here.
|
||||||
|
while len(self._buckets) >= self.max_buckets:
|
||||||
|
oldest_key = min(self._buckets, key=lambda k: self._buckets[k][1])
|
||||||
|
self._buckets.pop(oldest_key, None)
|
||||||
|
|
||||||
def allow(self, key: str) -> bool:
|
def allow(self, key: str) -> bool:
|
||||||
if self.rate <= 0:
|
if self.rate <= 0:
|
||||||
return True
|
return True
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if key not in self._buckets and len(self._buckets) >= self.max_buckets:
|
||||||
|
self._prune_locked(now)
|
||||||
tokens, last = self._buckets.get(key, (self.capacity, now))
|
tokens, last = self._buckets.get(key, (self.capacity, now))
|
||||||
tokens = min(self.capacity, tokens + (now - last) * self.rate)
|
tokens = min(self.capacity, tokens + (now - last) * self.rate)
|
||||||
if tokens < 1.0:
|
if tokens < 1.0:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.16.3",
|
"version": "0.16.6",
|
||||||
"description": "Control your browser from the terminal or Python SDK",
|
"description": "Control your browser from the terminal or Python SDK",
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import type { Job, Serializable, ErrorLike, DispatchArgs } from '../types';
|
|||||||
// jobs only need to survive long enough for the CLI to poll their result.
|
// jobs only need to survive long enough for the CLI to poll their result.
|
||||||
export const MAX_FINISHED_JOBS = 20;
|
export const MAX_FINISHED_JOBS = 20;
|
||||||
|
|
||||||
|
// Cap simultaneously running background jobs. A hung job has a watchdog, but a
|
||||||
|
// command flood could still pin many timers/results for up to JOB_TIMEOUT_MS.
|
||||||
|
// Rejecting above this bound keeps service-worker memory predictable.
|
||||||
|
export const MAX_RUNNING_JOBS = 32;
|
||||||
|
|
||||||
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
|
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
|
||||||
// dead tab), finalize the job as an error so its persist interval stops instead
|
// dead tab), finalize the job as an error so its persist interval stops instead
|
||||||
// of writing to api.storage.local every second forever.
|
// of writing to api.storage.local every second forever.
|
||||||
@@ -77,6 +82,11 @@ export class JobManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(command: string, args: DispatchArgs, runner: JobRunner) {
|
async start(command: string, args: DispatchArgs, runner: JobRunner) {
|
||||||
|
const runningCount = [...this.jobs.values()].filter(job => job.status === "running").length;
|
||||||
|
if (runningCount >= MAX_RUNNING_JOBS) {
|
||||||
|
throw new Error(`too many background jobs running (${runningCount}); wait for jobs to finish or cancel one`);
|
||||||
|
}
|
||||||
|
|
||||||
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
const job: Job = {
|
const job: Job = {
|
||||||
id: jobId,
|
id: jobId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core';
|
import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { PerfSetProfileArgs, JobIdArgs } from '../types';
|
import type { Job, PerfSetProfileArgs, JobIdArgs } from '../types';
|
||||||
|
|
||||||
// PerfCommands also owns the jobs.* status/cancel queries: they read the same
|
// PerfCommands also owns the jobs.* status/cancel queries: they read the same
|
||||||
// JobManager (ctx.jobs) that perf.status reports, and there is no dedicated
|
// JobManager (ctx.jobs) that perf.status reports, and there is no dedicated
|
||||||
@@ -15,15 +15,8 @@ export class PerfCommands extends CommandGroup {
|
|||||||
"jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a),
|
"jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a),
|
||||||
};
|
};
|
||||||
|
|
||||||
private async perfStatus() {
|
private jobSummary(job: Job) {
|
||||||
const profile = await getPerformanceProfile();
|
|
||||||
const audible = await hasAudibleTabs();
|
|
||||||
const throttle = await getLargeOperationThrottle(0, "auto");
|
|
||||||
return {
|
return {
|
||||||
performanceProfile: profile,
|
|
||||||
audible,
|
|
||||||
throttle,
|
|
||||||
jobs: this.ctx.jobs.list().map(job => ({
|
|
||||||
id: job.id,
|
id: job.id,
|
||||||
command: job.command,
|
command: job.command,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
@@ -32,7 +25,18 @@ export class PerfCommands extends CommandGroup {
|
|||||||
total: job.total,
|
total: job.total,
|
||||||
percent: job.percent,
|
percent: job.percent,
|
||||||
cancelRequested: job.cancelRequested,
|
cancelRequested: job.cancelRequested,
|
||||||
})),
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async perfStatus() {
|
||||||
|
const profile = await getPerformanceProfile();
|
||||||
|
const audible = await hasAudibleTabs();
|
||||||
|
const throttle = await getLargeOperationThrottle(0, "auto");
|
||||||
|
return {
|
||||||
|
performanceProfile: profile,
|
||||||
|
audible,
|
||||||
|
throttle,
|
||||||
|
jobs: this.ctx.jobs.list().map(job => this.jobSummary(job)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ import { CommandGroup } from '../classes/CommandGroup';
|
|||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types';
|
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 {
|
export class TabsQueryCommands extends CommandGroup {
|
||||||
readonly namespace = "tabs";
|
readonly namespace = "tabs";
|
||||||
readonly commands: Record<string, CommandEntry> = {
|
readonly commands: Record<string, CommandEntry> = {
|
||||||
@@ -50,12 +70,12 @@ export class TabsQueryCommands extends CommandGroup {
|
|||||||
|
|
||||||
private async tabsFilter({ pattern }: TabsPatternArgs) {
|
private async tabsFilter({ pattern }: TabsPatternArgs) {
|
||||||
const all = await api.tabs.query({});
|
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) {
|
private async tabsCount({ pattern }: TabsPatternArgs) {
|
||||||
const all = await api.tabs.query({});
|
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;
|
return all.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,31 @@ export class WindowsCommands extends CommandGroup {
|
|||||||
"windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a),
|
"windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private async activeWindowIds(): Promise<Set<number>> {
|
||||||
|
const windows = await api.windows.getAll({});
|
||||||
|
return new Set(windows.map(w => w.id).filter(id => typeof id === "number"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pruneAliases(activeIds?: Set<number>): Promise<Record<string, string>> {
|
||||||
|
const aliases = await getAliases();
|
||||||
|
const liveIds = activeIds || await this.activeWindowIds();
|
||||||
|
const pruned: Record<string, string> = {};
|
||||||
|
let changed = false;
|
||||||
|
for (const [id, alias] of Object.entries(aliases)) {
|
||||||
|
if (liveIds.has(Number(id))) {
|
||||||
|
pruned[id] = alias;
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) await api.storage.local.set({ windowAliases: pruned });
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
|
|
||||||
private async windowsList() {
|
private async windowsList() {
|
||||||
const windows = await api.windows.getAll({ populate: true });
|
const windows = await api.windows.getAll({ populate: true });
|
||||||
const aliases = await getAliases();
|
const activeIds = new Set(windows.map(w => w.id).filter(id => typeof id === "number"));
|
||||||
|
const aliases = await this.pruneAliases(activeIds);
|
||||||
return windows.map(w => ({
|
return windows.map(w => ({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
alias: aliases[w.id] || null,
|
alias: aliases[w.id] || null,
|
||||||
@@ -27,7 +49,7 @@ export class WindowsCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
|
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
|
||||||
const aliases = await getAliases();
|
const aliases = await this.pruneAliases();
|
||||||
aliases[windowId] = name;
|
aliases[windowId] = name;
|
||||||
await api.storage.local.set({ windowAliases: aliases });
|
await api.storage.local.set({ windowAliases: aliases });
|
||||||
return { windowId, name };
|
return { windowId, name };
|
||||||
@@ -35,6 +57,11 @@ export class WindowsCommands extends CommandGroup {
|
|||||||
|
|
||||||
private async windowsClose({ windowId }: WindowsCloseArgs) {
|
private async windowsClose({ windowId }: WindowsCloseArgs) {
|
||||||
await api.windows.remove(windowId);
|
await api.windows.remove(windowId);
|
||||||
|
const aliases = await this.pruneAliases();
|
||||||
|
if (windowId in aliases) {
|
||||||
|
delete aliases[windowId];
|
||||||
|
await api.storage.local.set({ windowAliases: aliases });
|
||||||
|
}
|
||||||
return { windowId };
|
return { windowId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { test, mock } from 'node:test';
|
import { test, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
|
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, MAX_RUNNING_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
|
||||||
import { makeChromeMock } from './chrome-mock';
|
import { makeChromeMock } from './chrome-mock';
|
||||||
|
|
||||||
// Drain pending microtasks (finalize() chains several awaits). setImmediate is
|
// Drain pending microtasks (finalize() chains several awaits). setImmediate is
|
||||||
@@ -129,6 +129,21 @@ test('JobManager: a runner that settles after the watchdog cannot resurrect the
|
|||||||
mock.timers.reset();
|
mock.timers.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('JobManager: rejects new background jobs above the running-job cap', async () => {
|
||||||
|
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||||
|
globalThis.chrome = makeChromeMock();
|
||||||
|
const mgr = new JobManager();
|
||||||
|
for (let i = 0; i < MAX_RUNNING_JOBS; i++) {
|
||||||
|
await mgr.start(`running${i}`, {}, () => new Promise(() => {}));
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
() => mgr.start('overflow', {}, async () => 'nope'),
|
||||||
|
/too many background jobs running/,
|
||||||
|
);
|
||||||
|
assert.equal(mgr.list().filter(job => job.status === 'running').length, MAX_RUNNING_JOBS);
|
||||||
|
mock.timers.reset();
|
||||||
|
});
|
||||||
|
|
||||||
test('JobManager: persisted set keeps running jobs even past the finished cap', async () => {
|
test('JobManager: persisted set keeps running jobs even past the finished cap', async () => {
|
||||||
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||||
globalThis.chrome = makeChromeMock();
|
globalThis.chrome = makeChromeMock();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { WindowsCommands } from '../src/commands/windows';
|
||||||
|
import { makeChromeMock } from './chrome-mock';
|
||||||
|
|
||||||
|
function makeWindowsChromeMock(windows) {
|
||||||
|
const chrome = makeChromeMock();
|
||||||
|
chrome.windows = {
|
||||||
|
getAll: async () => windows,
|
||||||
|
remove: async () => {},
|
||||||
|
create: async () => ({ id: 99 }),
|
||||||
|
};
|
||||||
|
return chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('windows.list prunes aliases for closed windows', async () => {
|
||||||
|
globalThis.chrome = makeWindowsChromeMock([
|
||||||
|
{ id: 1, focused: true, state: 'normal', tabs: [{ id: 10 }] },
|
||||||
|
{ id: 2, focused: false, state: 'minimized', tabs: [] },
|
||||||
|
]);
|
||||||
|
globalThis.chrome.storage.local._store.windowAliases = {
|
||||||
|
1: 'main',
|
||||||
|
2: 'side',
|
||||||
|
999: 'closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const commands = new WindowsCommands({ jobs: {} });
|
||||||
|
const result = await commands.commands['windows.list']({});
|
||||||
|
|
||||||
|
assert.deepEqual(result.map(w => [w.id, w.alias]), [[1, 'main'], [2, 'side']]);
|
||||||
|
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'side' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windows.rename prunes stale aliases before saving the new name', async () => {
|
||||||
|
globalThis.chrome = makeWindowsChromeMock([
|
||||||
|
{ id: 1, focused: true, state: 'normal', tabs: [] },
|
||||||
|
{ id: 2, focused: false, state: 'normal', tabs: [] },
|
||||||
|
]);
|
||||||
|
globalThis.chrome.storage.local._store.windowAliases = {
|
||||||
|
1: 'main',
|
||||||
|
999: 'closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const commands = new WindowsCommands({ jobs: {} });
|
||||||
|
await commands.commands['windows.rename']({ windowId: 2, name: 'work' });
|
||||||
|
|
||||||
|
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'work' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windows.close removes the closed window alias immediately', async () => {
|
||||||
|
let removed = null;
|
||||||
|
globalThis.chrome = makeWindowsChromeMock([
|
||||||
|
{ id: 1, focused: true, state: 'normal', tabs: [] },
|
||||||
|
{ id: 2, focused: false, state: 'normal', tabs: [] },
|
||||||
|
]);
|
||||||
|
globalThis.chrome.windows.remove = async id => { removed = id; };
|
||||||
|
globalThis.chrome.storage.local._store.windowAliases = {
|
||||||
|
1: 'main',
|
||||||
|
2: 'side',
|
||||||
|
999: 'closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const commands = new WindowsCommands({ jobs: {} });
|
||||||
|
await commands.commands['windows.close']({ windowId: 2 });
|
||||||
|
|
||||||
|
assert.equal(removed, 2);
|
||||||
|
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main' });
|
||||||
|
});
|
||||||
@@ -44,27 +44,56 @@ Paste the contents of `n8n_key.pem` into the n8n credential.
|
|||||||
| Port | `serve` TCP port (default `8765`) |
|
| Port | `serve` TCP port (default `8765`) |
|
||||||
| Ed25519 Private Key | PKCS8 PEM from `browser-cli auth keygen` (empty only for `--no-auth` loopback) |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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
|
## Operations
|
||||||
Every operation maps to one raw browser-cli command, each subject to the server
|
Every operation maps to one raw browser-cli command, each subject to the server
|
||||||
policy tier noted below.
|
policy tier noted below.
|
||||||
|
|
||||||
| Resource | Operation | Command | Server flag needed |
|
| Resource | Operation | Command | Server flag needed |
|
||||||
|----------|-----------|---------|--------------------|
|
|----------|-----------|---------|--------------------|
|
||||||
| Tab | List | `tabs.list` | safe (default) |
|
| 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 | Open | `navigate.open` | `--allow-control` |
|
|
||||||
| Tab | Close | `tabs.close` (ids / inactive / duplicates) | `--allow-control` |
|
|
||||||
| Tab | Get HTML | `tabs.html` | `--allow-read-page` |
|
| 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 | Get Info | `page.info` | safe (default) |
|
||||||
| Page | Extract Text / Links / Images / HTML / Markdown | `extract.*` | `--allow-read-page` |
|
| Page | Extract Text / Links / Images / HTML / Markdown / JSON | `extract.*` | `--allow-read-page` |
|
||||||
| DOM | Query | `dom.query` | `--allow-read-page` |
|
| DOM | Query / Text / Attribute / Exists | `dom.query` / `dom.text` / `dom.attr` / `dom.exists` | `--allow-read-page` |
|
||||||
| DOM | Click / Type | `dom.click` / `dom.type` | `--allow-control` |
|
| DOM | Click / Type / Select / Hover / Focus / Check / Uncheck / Clear / Submit / Scroll / Key | `dom.*` | `--allow-control` |
|
||||||
| DOM | Eval | `dom.eval` | `--allow-dangerous` |
|
| 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) |
|
| Client | List | `clients.list` | safe (default) |
|
||||||
| Command | Execute | any command name + JSON args | per command |
|
| 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
|
**Command → Execute** is the escape hatch: any command string the server policy
|
||||||
allows (`tabs.query`, `session.save`, `windows.list`, …) with a JSON args object.
|
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
|
> `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**.
|
> 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
|
## Develop / build
|
||||||
```bash
|
```bash
|
||||||
cd n8n-nodes-browser-cli
|
cd n8n-nodes-browser-cli
|
||||||
|
|||||||
@@ -65,6 +65,23 @@ export class BrowserCliApi implements ICredentialType {
|
|||||||
description:
|
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.',
|
'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',
|
displayName: 'Use TLS',
|
||||||
name: 'tls',
|
name: 'tls',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class BrowserCli implements INodeType {
|
|||||||
icon: 'file:browserCli.svg',
|
icon: 'file:browserCli.svg',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: 1,
|
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',
|
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
|
||||||
defaults: { name: 'Browser CLI' },
|
defaults: { name: 'Browser CLI' },
|
||||||
inputs: [NodeConnectionTypes.Main],
|
inputs: [NodeConnectionTypes.Main],
|
||||||
@@ -43,9 +43,14 @@ export class BrowserCli implements INodeType {
|
|||||||
{ name: 'Tab', value: 'tab' },
|
{ name: 'Tab', value: 'tab' },
|
||||||
{ name: 'Page', value: 'page' },
|
{ name: 'Page', value: 'page' },
|
||||||
{ name: 'DOM', value: 'dom' },
|
{ 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: 'Client', value: 'client' },
|
||||||
{ name: 'Command', value: 'command' },
|
{ name: 'Command', value: 'command' },
|
||||||
{ name: 'Gateway', value: 'gateway' },
|
|
||||||
],
|
],
|
||||||
default: 'tab',
|
default: 'tab',
|
||||||
},
|
},
|
||||||
@@ -59,9 +64,29 @@ export class BrowserCli implements INodeType {
|
|||||||
displayOptions: { show: { resource: ['tab'] } },
|
displayOptions: { show: { resource: ['tab'] } },
|
||||||
options: [
|
options: [
|
||||||
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
|
{ 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: '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: '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: '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',
|
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 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 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 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',
|
default: 'extractText',
|
||||||
},
|
},
|
||||||
@@ -93,13 +119,122 @@ export class BrowserCli implements INodeType {
|
|||||||
displayOptions: { show: { resource: ['dom'] } },
|
displayOptions: { show: { resource: ['dom'] } },
|
||||||
options: [
|
options: [
|
||||||
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
|
{ 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: '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: '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)' },
|
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
|
||||||
],
|
],
|
||||||
default: 'query',
|
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 ------------------------------------------------
|
// --- Client operations ------------------------------------------------
|
||||||
{
|
{
|
||||||
displayName: 'Operation',
|
displayName: 'Operation',
|
||||||
@@ -126,18 +261,6 @@ export class BrowserCli implements INodeType {
|
|||||||
default: 'execute',
|
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 -----------------------------------------
|
// --- Shared parameter fields -----------------------------------------
|
||||||
{
|
{
|
||||||
@@ -147,7 +270,16 @@ export class BrowserCli implements INodeType {
|
|||||||
default: '',
|
default: '',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'https://example.com',
|
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',
|
displayName: 'Focus Tab',
|
||||||
@@ -184,7 +316,136 @@ export class BrowserCli implements INodeType {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
default: 0,
|
default: 0,
|
||||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
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',
|
displayName: 'Selector',
|
||||||
@@ -196,7 +457,11 @@ export class BrowserCli implements INodeType {
|
|||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
resource: ['dom', 'page'],
|
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,
|
required: true,
|
||||||
displayOptions: { show: showFor('dom', ['type']) },
|
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',
|
displayName: 'JavaScript',
|
||||||
name: 'code',
|
name: 'code',
|
||||||
@@ -220,12 +530,97 @@ export class BrowserCli implements INodeType {
|
|||||||
displayOptions: { show: showFor('dom', ['eval']) },
|
displayOptions: { show: showFor('dom', ['eval']) },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Tab ID',
|
displayName: 'Group ID',
|
||||||
name: 'tabId',
|
name: 'groupId',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
default: 0,
|
default: 0,
|
||||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
required: true,
|
||||||
displayOptions: { show: { resource: ['dom'], operation: ['eval'] } },
|
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',
|
displayName: 'Command',
|
||||||
@@ -332,6 +727,8 @@ function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions
|
|||||||
rejectUnauthorized: !creds.allowUnauthorizedCerts,
|
rejectUnauthorized: !creds.allowUnauthorizedCerts,
|
||||||
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
|
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
|
||||||
route: creds.browser ? String(creds.browser) : 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 };
|
return { command: get('command'), args };
|
||||||
}
|
}
|
||||||
|
// --- Tabs -------------------------------------------------------------
|
||||||
case 'tab:open':
|
case 'tab:open':
|
||||||
return { url: get('url'), focus: get('focus', false) };
|
return { url: get('url'), focus: get('focus', false) };
|
||||||
|
case 'tab:navigateTo':
|
||||||
|
return { tabId: get('tabId', 0), url: get('url') };
|
||||||
case 'tab:close':
|
case 'tab:close':
|
||||||
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
|
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) };
|
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:query':
|
||||||
|
case 'dom:text':
|
||||||
|
case 'dom:exists':
|
||||||
case 'dom:click':
|
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', '') };
|
return { selector: get('selector', '') };
|
||||||
case 'dom:type':
|
case 'dom:type':
|
||||||
return { selector: get('selector', ''), text: get('text', '') };
|
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':
|
case 'dom:eval':
|
||||||
return { code: get('code', ''), tabId: get('tabId', 0) };
|
return { code: get('code', ''), tabId: get('tabId', 0) };
|
||||||
|
|
||||||
|
// --- Page / extraction ------------------------------------------------
|
||||||
case 'page:extractText':
|
case 'page:extractText':
|
||||||
case 'page:extractLinks':
|
case 'page:extractLinks':
|
||||||
case 'page:extractImages':
|
case 'page:extractImages':
|
||||||
case 'page:extractHtml':
|
case 'page:extractHtml':
|
||||||
case 'page:extractMarkdown':
|
case 'page:extractMarkdown':
|
||||||
|
case 'page:extractJson':
|
||||||
return { selector: get('selector', '') };
|
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:
|
default:
|
||||||
return {};
|
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>
|
<title>browser-cli icon</title>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
|
<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,
|
createPrivateKey,
|
||||||
createPublicKey,
|
createPublicKey,
|
||||||
createHash,
|
createHash,
|
||||||
|
verify as nodeVerify,
|
||||||
createHmac,
|
createHmac,
|
||||||
createCipheriv,
|
createCipheriv,
|
||||||
createDecipheriv,
|
createDecipheriv,
|
||||||
@@ -234,6 +235,8 @@ export interface Challenge {
|
|||||||
nonce?: string;
|
nonce?: string;
|
||||||
min_client_version?: string;
|
min_client_version?: string;
|
||||||
pq_kex?: { alg?: string; public_key?: string };
|
pq_kex?: { alg?: string; public_key?: string };
|
||||||
|
server_pubkey?: string;
|
||||||
|
server_sig?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthPayload {
|
export interface AuthPayload {
|
||||||
@@ -247,6 +250,81 @@ function pqPublicKey(challenge: Challenge): string | null {
|
|||||||
return 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.
|
* Build the single framed message a client sends in response to the challenge.
|
||||||
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
|
* 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;
|
* (see `serveClient.ts`). Every operation maps to one raw extension command;
|
||||||
* what the server returns is the *raw* command result (no SDK-side rendering),
|
* what the server returns is the *raw* command result (no SDK-side rendering),
|
||||||
* still subject to the server's --allow-* policy noted per operation.
|
* 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>;
|
export type CommandParams = Record<string, unknown>;
|
||||||
@@ -51,6 +55,16 @@ export function buildCommand(
|
|||||||
// --- Tabs -------------------------------------------------------------
|
// --- Tabs -------------------------------------------------------------
|
||||||
case 'tab:list':
|
case 'tab:list':
|
||||||
return { command: 'tabs.list', args: {} };
|
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': {
|
case 'tab:open': {
|
||||||
const focus = Boolean(params.focus);
|
const focus = Boolean(params.focus);
|
||||||
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
|
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
|
||||||
@@ -63,6 +77,50 @@ export function buildCommand(
|
|||||||
}
|
}
|
||||||
case 'tab:getHtml':
|
case 'tab:getHtml':
|
||||||
return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
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 ------------------------------------------------
|
// --- Page / extraction ------------------------------------------------
|
||||||
case 'page:info':
|
case 'page:info':
|
||||||
@@ -77,6 +135,8 @@ export function buildCommand(
|
|||||||
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
|
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
|
||||||
case 'page:extractMarkdown':
|
case 'page:extractMarkdown':
|
||||||
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
|
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
|
||||||
|
case 'page:extractJson':
|
||||||
|
return { command: 'extract.json', args: { selector: str(params, 'selector') } };
|
||||||
|
|
||||||
// --- DOM --------------------------------------------------------------
|
// --- DOM --------------------------------------------------------------
|
||||||
case 'dom:query':
|
case 'dom:query':
|
||||||
@@ -85,17 +145,118 @@ export function buildCommand(
|
|||||||
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
|
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
|
||||||
case 'dom:type':
|
case 'dom:type':
|
||||||
return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } };
|
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':
|
case 'dom:eval':
|
||||||
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
|
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 ----------------------------------------------------------
|
// --- Clients ----------------------------------------------------------
|
||||||
case 'client:list':
|
case 'client:list':
|
||||||
return { command: 'clients.list', args: {} };
|
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:
|
default:
|
||||||
throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`);
|
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;
|
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. */
|
/** Accept an array, a JSON array string, or a comma/space separated list. */
|
||||||
export function parseTabIds(value: unknown): number[] {
|
export function parseTabIds(value: unknown): number[] {
|
||||||
if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite);
|
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 type { Socket } from 'node:net';
|
||||||
import { randomUUID } from 'node:crypto';
|
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
|
/** 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
|
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
|
||||||
* client implements. */
|
* client implements. */
|
||||||
const CLIENT_VERSION = '0.15.4';
|
const CLIENT_VERSION = '0.16.0';
|
||||||
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
|
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
|
||||||
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
|
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
|
||||||
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
|
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
|
||||||
@@ -33,6 +33,10 @@ export interface ServeConnectOptions {
|
|||||||
privateKeyPem?: string | null;
|
privateKeyPem?: string | null;
|
||||||
/** Optional `_route` target for a multi-browser serve. */
|
/** Optional `_route` target for a multi-browser serve. */
|
||||||
route?: string | null;
|
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;
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +125,12 @@ export async function sendServeCommand(
|
|||||||
const run = async () => {
|
const run = async () => {
|
||||||
const challengeRaw = await reader.next();
|
const challengeRaw = await reader.next();
|
||||||
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
|
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> = {
|
const baseMsg: Record<string, unknown> = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-nodes-browser-cli",
|
"name": "n8n-nodes-browser-cli",
|
||||||
"version": "0.2.4",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-nodes-browser-cli",
|
"name": "n8n-nodes-browser-cli",
|
||||||
"version": "0.2.4",
|
"version": "0.3.1",
|
||||||
"license": "PolyForm-Noncommercial-1.0.0",
|
"license": "PolyForm-Noncommercial-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/post-quantum": "^0.6.1"
|
"@noble/post-quantum": "^0.6.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-nodes-browser-cli",
|
"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)",
|
"description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"n8n-community-node-package",
|
"n8n-community-node-package",
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
pqDecrypt,
|
pqDecrypt,
|
||||||
pqEncrypt,
|
pqEncrypt,
|
||||||
pqTransportKey,
|
pqTransportKey,
|
||||||
|
serverFingerprint,
|
||||||
signAuth,
|
signAuth,
|
||||||
|
verifyServerChallengeSignature,
|
||||||
|
verifyServerIdentity,
|
||||||
} from '../nodes/BrowserCli/protocol';
|
} from '../nodes/BrowserCli/protocol';
|
||||||
|
|
||||||
// Known-answer vectors produced by the real Python implementation
|
// Known-answer vectors produced by the real Python implementation
|
||||||
@@ -49,6 +52,19 @@ const DECRYPT_ENV = {
|
|||||||
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
|
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
|
||||||
};
|
};
|
||||||
const DECRYPT_PLAIN = 'hello world payload';
|
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)', () => {
|
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
|
||||||
assert.equal(canonicalJson(MSG), CANON);
|
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 }));
|
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
|
||||||
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
|
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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ test('client:list maps to clients.list', () => {
|
|||||||
assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} });
|
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', () => {
|
test('tab:open sends navigate.open with background derived from focus', () => {
|
||||||
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
|
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
|
||||||
assert.deepEqual(bg, {
|
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', () => {
|
test('unknown operation throws', () => {
|
||||||
assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/);
|
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', () => {
|
test('parseTabIds accepts array, json, and delimited strings', () => {
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "real-browser-cli"
|
name = "real-browser-cli"
|
||||||
version = "0.16.3"
|
version = "0.16.6"
|
||||||
description = "Control your real running browser from the terminal or Python SDK"
|
description = "Control your real running browser from the terminal or Python SDK"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -22,9 +22,13 @@ Issues = "https://git.yiprawr.dev/Automatisation/browser-cli/issues"
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# Better/faster remote response compression than the stdlib zlib/gzip fallback.
|
# Better/faster remote response compression than the stdlib zlib/gzip fallback.
|
||||||
fast = ["zstandard>=0.22"]
|
fast = ["zstandard>=0.22"]
|
||||||
|
mcp = [
|
||||||
|
"mcp>=2,<3",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
browser-cli = "browser_cli.cli:main"
|
browser-cli = "browser_cli.cli:main"
|
||||||
|
browser-cli-mcp = "browser_cli.mcp.server:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ from browser_cli.remote import pool as _remote_pool
|
|||||||
|
|
||||||
TEST_BROWSER_PROFILE = "testing"
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_remote_pool():
|
def _clear_remote_pool():
|
||||||
"""Close any pooled remote connections between tests so a connection opened
|
"""Close any pooled remote connections between tests so a connection opened
|
||||||
|
|||||||
+34
-11
@@ -294,29 +294,52 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
|||||||
assert "0.8.2" in result.output
|
assert "0.8.2" in result.output
|
||||||
|
|
||||||
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
target = BrowserTarget(
|
||||||
assert command == "clients.list"
|
profile="work",
|
||||||
assert profile is None
|
display_name="127.0.0.1:work",
|
||||||
assert remote == "127.0.0.1:8765"
|
socket_path="",
|
||||||
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
|
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(
|
with patch.dict(os.environ, {}, clear=True), patch(
|
||||||
"browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
|
"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"])
|
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
send_command.assert_called_once()
|
send_command.assert_not_called()
|
||||||
assert "remote" in result.output
|
assert "work" in result.output
|
||||||
|
assert "127.0.0.1" not in result.output
|
||||||
assert "Chrome" in result.output
|
assert "Chrome" in result.output
|
||||||
assert "2.3.4" in result.output
|
assert "2.3.4" in result.output
|
||||||
|
|
||||||
def test_clients_remote_respects_global_browser_route():
|
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"])
|
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 0
|
||||||
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
|
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():
|
def test_clients_browser_alias_resolves_to_remote():
|
||||||
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
|
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
|
||||||
|
|||||||
+66
-15
@@ -356,7 +356,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
|||||||
assert targets[0].display_group == "browser-host.example"
|
assert targets[0].display_group == "browser-host.example"
|
||||||
|
|
||||||
def test_looks_like_domain():
|
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("browser-host.example") is True
|
||||||
assert _looks_like_domain("sub.domain.org") is True
|
assert _looks_like_domain("sub.domain.org") is True
|
||||||
assert _looks_like_domain("localhost") is False
|
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
|
assert _looks_like_domain("host") is False # no dot
|
||||||
|
|
||||||
def test_normalize_endpoint_strips_443_for_domains():
|
def test_normalize_endpoint_strips_443_for_domains():
|
||||||
assert _normalize_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev"
|
assert _normalize_endpoint("browser-host.example:443") == "browser-host.example"
|
||||||
assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev"
|
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("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("localhost:443") == "localhost:443" # localhost: keep port
|
||||||
assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged
|
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():
|
def test_resolve_connect_endpoint_adds_443_for_domain():
|
||||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443"
|
assert _resolve_connect_endpoint("browser-host.example") == "browser-host.example:443"
|
||||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443"
|
assert _resolve_connect_endpoint("browser-host.example:443") == "browser-host.example:443"
|
||||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
|
assert _resolve_connect_endpoint("browser-host.example:8765") == "browser-host.example:8765"
|
||||||
assert _resolve_connect_endpoint("host:8765") == "host:8765"
|
assert _resolve_connect_endpoint("host:8765") == "host:8765"
|
||||||
|
|
||||||
def test_resolve_connect_endpoint_raises_for_bare_non_domain():
|
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)
|
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 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):
|
def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
||||||
"""--remote domain (no port) is treated as :443."""
|
"""--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)
|
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 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):
|
def test_domain_display_name_omits_port(monkeypatch, tmp_path):
|
||||||
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
|
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
|
||||||
remotes_path = tmp_path / "remotes.json"
|
remotes_path = tmp_path / "remotes.json"
|
||||||
endpoint = "browsercli.yiprawr.dev"
|
endpoint = "browser-host.example"
|
||||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
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.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||||
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
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()
|
targets = active_browser_targets()
|
||||||
|
|
||||||
assert len(targets) == 1
|
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
|
assert targets[0].remote == endpoint
|
||||||
|
|
||||||
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
|
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
|
||||||
"""Old remotes.json with :443 still displays cleanly without the port."""
|
"""Old remotes.json with :443 still displays cleanly without the port."""
|
||||||
remotes_path = tmp_path / "remotes.json"
|
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")
|
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.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||||
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
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()
|
targets = active_browser_targets()
|
||||||
|
|
||||||
assert len(targets) == 1
|
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):
|
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."""
|
"""--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",
|
"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):
|
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."""
|
"""An older remote (no advertised version) still triggers a clients.list query."""
|
||||||
from browser_cli.client import collect_browser_clients
|
from browser_cli.client import collect_browser_clients
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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) == {}
|
||||||
@@ -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,
|
||||||
|
}]
|
||||||
@@ -43,6 +43,39 @@ def test_checkin_caps_pool_size():
|
|||||||
b.close()
|
b.close()
|
||||||
pool.close_all()
|
pool.close_all()
|
||||||
|
|
||||||
|
def test_checkin_caps_endpoint_buckets():
|
||||||
|
pool.close_all()
|
||||||
|
peers = []
|
||||||
|
try:
|
||||||
|
for i in range(pool._MAX_ENDPOINTS + 5):
|
||||||
|
a, b = _socketpair()
|
||||||
|
peers.append(b)
|
||||||
|
pool.checkin(f"host-{i}:443", pool.PooledConnection(a, b"secret"))
|
||||||
|
assert len(pool._POOL) <= pool._MAX_ENDPOINTS
|
||||||
|
finally:
|
||||||
|
for peer in peers:
|
||||||
|
peer.close()
|
||||||
|
pool.close_all()
|
||||||
|
|
||||||
|
def test_checkin_prunes_stale_endpoint_buckets():
|
||||||
|
pool.close_all()
|
||||||
|
old_a, old_b = _socketpair()
|
||||||
|
old = pool.PooledConnection(old_a, b"secret")
|
||||||
|
pool.checkin("old:443", old)
|
||||||
|
old.last_used -= pool._MAX_IDLE_SECONDS + 1
|
||||||
|
peers = [old_b]
|
||||||
|
try:
|
||||||
|
for i in range(pool._MAX_ENDPOINTS):
|
||||||
|
a, b = _socketpair()
|
||||||
|
peers.append(b)
|
||||||
|
pool.checkin(f"new-{i}:443", pool.PooledConnection(a, b"secret"))
|
||||||
|
assert "old:443" not in pool._POOL
|
||||||
|
assert len(pool._POOL) <= pool._MAX_ENDPOINTS
|
||||||
|
finally:
|
||||||
|
for peer in peers:
|
||||||
|
peer.close()
|
||||||
|
pool.close_all()
|
||||||
|
|
||||||
def test_session_inner_message_strips_auth_fields():
|
def test_session_inner_message_strips_auth_fields():
|
||||||
msg = {
|
msg = {
|
||||||
"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1",
|
"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1",
|
||||||
|
|||||||
@@ -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() == {}
|
||||||
@@ -228,33 +228,6 @@ class TestAuthSuccess:
|
|||||||
client.close()
|
client.close()
|
||||||
t.join(timeout=2)
|
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):
|
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||||
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
|
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
|
||||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||||
|
|||||||
@@ -181,6 +181,23 @@ def test_rate_limiter_is_per_key():
|
|||||||
assert limiter.allow("a") is False
|
assert limiter.allow("a") is False
|
||||||
assert limiter.allow("b") is False
|
assert limiter.allow("b") is False
|
||||||
|
|
||||||
|
def test_rate_limiter_caps_identity_buckets():
|
||||||
|
limiter = RateLimiter(rate=0.0001, burst=1, max_buckets=3)
|
||||||
|
for i in range(10):
|
||||||
|
assert limiter.allow(f"key-{i}") is True
|
||||||
|
assert len(limiter._buckets) <= 3
|
||||||
|
|
||||||
|
def test_rate_limiter_prunes_refilled_idle_buckets(monkeypatch):
|
||||||
|
current = 1000.0
|
||||||
|
monkeypatch.setattr("browser_cli.serve.security.time.monotonic", lambda: current)
|
||||||
|
limiter = RateLimiter(rate=1, burst=2, max_buckets=2)
|
||||||
|
assert limiter.allow("old") is True
|
||||||
|
current += 120.0
|
||||||
|
assert limiter.allow("a") is True
|
||||||
|
assert limiter.allow("b") is True
|
||||||
|
assert "old" not in limiter._buckets
|
||||||
|
assert len(limiter._buckets) <= 2
|
||||||
|
|
||||||
# ── ServeSecurity ────────────────────────────────────────────────────────────────
|
# ── ServeSecurity ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_effective_policy_prefers_per_key_override():
|
def test_effective_policy_prefers_per_key_override():
|
||||||
|
|||||||
Reference in New Issue
Block a user