Compare commits

...
7 Commits
Author SHA1 Message Date
daniel156161 6352d9994e Bound extension job and window-alias state
Testing / test (push) Successful in 48s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 39s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 41s
Two service-worker leaks. Running jobs were only bounded by their watchdog, so
a command flood could hold many timers and results for up to JOB_TIMEOUT_MS;
cap concurrent jobs at 32 and reject beyond that, since a clear error beats an
unresponsive worker. Window aliases were never removed, so storage kept an
entry for every window the user had ever renamed.

Prune aliases against the live window set on list, rename, and close rather
than only on close, because windows also disappear without going through
windows.close.

Also extract the repeated job summary in perf.status into a helper.
2026-08-09 20:49:37 +02:00
daniel156161 541b950519 Bound remote pool and rate-limiter memory
Both tables grew one entry per distinct key and never shrank, so a long-running
SDK client or a public serve process accumulated state for every endpoint or
identity it had ever seen. GC cannot reclaim them while the pool and limiter
still reference them.

Cap the pool at 64 endpoint buckets and the limiter at 4096 buckets. Both evict
useless state first: connections past the idle timeout the server has likely
dropped anyway, and buckets that have fully refilled, which carry no throttling
information. Only then fall back to evicting the oldest entry.

Evicting a limiter bucket resets throttling for that identity, which is the
deliberate trade: an attacker cycling identities faster than they go idle can
regain tokens, but unbounded growth would take the process down instead.
2026-08-09 20:49:35 +02:00
daniel156161 1b32410575 Isolate the remote registry in tests
Testing / remote-protocol-compat (0.16.0) (push) Successful in 40s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 42s
Testing / test (push) Successful in 49s
Endpoint resolution consults remembered remotes, so tests that assert the
bare-domain :443 default failed on any machine with an explicit-port remote
remembered for the same host, while passing in CI. That made a real behavior
difference look like a local-only flake.

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

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

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

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

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

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

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

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

- Suppress remote host group headers for scoped client queries.

- Keep global and mixed client listings grouped by host.

- Update client and CLI tests for scoped remote rendering.

- Bump browser-cli and extension versions to 0.16.5.
2026-06-26 09:12:44 +02:00
26 changed files with 1899 additions and 230 deletions
+88
View File
@@ -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`.
+11 -8
View File
@@ -151,21 +151,25 @@ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_p
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning)) 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 = []
@@ -249,18 +253,17 @@ def collect_browser_clients(
if targets: if targets:
uncached = [] uncached = []
for target in targets: for target in targets:
cached = _cached_client_row(target) cached = _cached_client_row(target, scoped=True)
if cached is not None: if cached is not None:
rows.append(cached) rows.append(cached)
else: else:
uncached.append(target) uncached.append(target)
results = _run_concurrent([ results = _run_concurrent([
(lambda t=t: _client_rows_async( (lambda t=t: _client_rows_async(
t.display_name, t.profile,
profile=t.profile, profile=t.profile,
remote=remote, remote=remote,
key=key, key=key,
profile_group=t.display_group,
)) ))
for t in uncached for t in uncached
]) ])
+2 -2
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
"""Stateless Model Context Protocol adapter for browser-cli."""
from browser_cli.mcp.server import create_server, main
__all__ = ["create_server", "main"]
+39
View File
@@ -0,0 +1,39 @@
"""Tool-name prefixing for the MCP surface.
Hosts differ in how they namespace MCP tools. Hosts that already prefix tool
names with the server name turn the built-in ``browser_`` prefix into stutter
(``browser_cli_testing_browser_tabs_list``), while hosts that expose raw names
need the prefix to keep ``navigate`` or ``screenshot`` unambiguous. The prefix
is therefore configurable per MCP server process.
"""
from __future__ import annotations
import os
import re
TOOL_PREFIX_ENV = "BROWSER_CLI_MCP_TOOL_PREFIX"
DEFAULT_TOOL_PREFIX = "browser_"
_VALID_PREFIX = re.compile(r"\A[a-z][a-z0-9_]*\Z")
def resolve_tool_prefix(environ: dict[str, str] | None = None) -> str:
"""Return the configured tool-name prefix, defaulting to ``browser_``.
An empty value disables prefixing for hosts that namespace tools themselves.
"""
configured = (environ if environ is not None else os.environ).get(TOOL_PREFIX_ENV)
if configured is None:
return DEFAULT_TOOL_PREFIX
prefix = configured.strip()
if not prefix:
return ""
if not _VALID_PREFIX.match(prefix):
raise ValueError(
f"{TOOL_PREFIX_ENV} must be lowercase letters, digits, and underscores starting "
f"with a letter, or empty to disable prefixing; got {configured!r}"
)
return prefix if prefix.endswith("_") else f"{prefix}_"
def tool_name(base: str, prefix: str) -> str:
"""Apply *prefix* to a bare tool name such as ``tabs_list``."""
return f"{prefix}{base}"
+19
View File
@@ -0,0 +1,19 @@
"""Convert browser-cli SDK models into MCP structured-output values."""
from __future__ import annotations
from dataclasses import fields, is_dataclass
from typing import Any
def structured(value: Any) -> Any:
"""Return JSON-compatible data without private SDK binding fields."""
if is_dataclass(value) and not isinstance(value, type):
return {
field.name: structured(getattr(value, field.name))
for field in fields(value)
if not field.name.startswith("_")
}
if isinstance(value, dict):
return {str(key): structured(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [structured(item) for item in value]
return value
+231
View File
@@ -0,0 +1,231 @@
"""Stateless MCP server exposing a conservative browser-cli tool surface.
The MCP process stores no browser client, tab ID, or navigation state. Every
call constructs a fresh :class:`browser_cli.BrowserCLI`; the real browser is
the sole owner of browser state.
"""
from __future__ import annotations
import argparse
import base64
import os
from collections.abc import Callable
from typing import Any, Literal
from urllib.parse import urlsplit
from browser_cli import BrowserCLI
from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.targets import resolve_tab_id
ClientFactory = Callable[..., BrowserCLI]
_SERVER_INSTRUCTIONS = """Control a real, user-visible browser through browser-cli.
The server is stateless: pass browser, remote, and key on each tool call when a
specific target is required. Tool calls affect the user's actual browser. Read
current tabs/page state instead of assuming IDs or content from an earlier call.
"""
def _client(factory: ClientFactory, browser: str | None, remote: str | None, key: str | None) -> BrowserCLI:
"""Build a fresh client, making MCP process environment defaults explicit.
Explicit values are important for SDK multi-browser routing: leaving
``browser=None`` would fan out list/count calls before lower transport code
gets a chance to consult ``BROWSER_CLI_PROFILE``.
"""
return factory(
browser=browser or os.environ.get("BROWSER_CLI_PROFILE"),
remote=remote or os.environ.get("BROWSER_CLI_REMOTE"),
key=key or os.environ.get("BROWSER_CLI_KEY"),
)
def _screenshot_bytes(data_url: str) -> tuple[bytes, str]:
"""Decode a browser screenshot data URL into bytes and an MCP image format."""
header, separator, payload = data_url.partition(",")
if not separator or ";base64" not in header:
raise ValueError("Browser returned an invalid screenshot data URL")
media_type = header[5:].split(";", 1)[0].lower()
image_format = "jpeg" if media_type in {"image/jpeg", "image/jpg"} else "png"
return base64.b64decode(payload, validate=True), image_format
def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: str | None = None):
"""Create the MCP server. Supplying *client_factory* keeps tests browser-free."""
try:
from mcp.server import MCPServer
from mcp.server.mcpserver import Image
except ImportError as exc: # pragma: no cover - exercised without the optional extra
raise RuntimeError(
"MCP support is not installed. Install real-browser-cli with the 'mcp' extra: "
"uv tool install 'real-browser-cli[mcp]'"
) from exc
prefix = resolve_tool_prefix() if tool_prefix is None else tool_prefix
mcp = MCPServer(
"browser-cli",
description="Control a real running browser through the browser-cli SDK.",
instructions=_SERVER_INSTRUCTIONS,
)
@mcp.tool(name=tool_name("tabs_list", prefix))
def tabs_list(
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> list[dict[str, Any]]:
"""List current tabs. Optionally target a browser alias or authenticated remote."""
return structured(_client(client_factory, browser, remote, key).tabs.list())
@mcp.tool(name=tool_name("tabs_open", prefix))
def tabs_open(
url: str,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Open a URL in a new real-browser tab and return its current metadata."""
tab = _client(client_factory, browser, remote, key).tabs.open(
url, wait=wait, timeout=timeout, background=background, focus=focus
)
return structured(tab)
@mcp.tool(name=tool_name("tabs_close", prefix))
def tabs_close(
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, int]:
"""Close a tab, defaulting to the active tab. This changes the real browser."""
client = _client(client_factory, browser, remote, key)
target = resolve_tab_id(client, tab_id)
return {"closed": client.tabs.close(target), "tab_id": target}
@mcp.tool(name=tool_name("navigate", prefix))
def navigate(
url: str,
tab_id: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Navigate a tab to a URL, defaulting to the active tab, and return it."""
client = _client(client_factory, browser, remote, key)
target = resolve_tab_id(client, tab_id)
client.nav.to(target, url)
return structured(client.tabs.status(target))
@mcp.tool(name=tool_name("page_info", prefix))
def page_info(
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Return title, URL, readiness, language, and metadata for the active page."""
return structured(_client(client_factory, browser, remote, key).page.info())
@mcp.tool(name=tool_name("extract_text", prefix))
def extract_text(
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> str:
"""Extract plain text from the active page."""
return _client(client_factory, browser, remote, key).extract.text()
@mcp.tool(name=tool_name("extract_markdown", prefix))
def extract_markdown(
selector: str | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> str:
"""Extract clean Markdown from the active page or an optional CSS selector."""
return _client(client_factory, browser, remote, key).extract.markdown(selector)
@mcp.tool(name=tool_name("dom_query", prefix))
def dom_query(
selector: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> list[dict[str, Any]]:
"""Return elements matching a CSS selector on the active page."""
return structured(_client(client_factory, browser, remote, key).dom.query(selector))
@mcp.tool(name=tool_name("dom_click", prefix))
def dom_click(
selector: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Click the first matching element, then return current active-page info."""
client = _client(client_factory, browser, remote, key)
client.dom.click(selector)
return structured(client.page.info())
@mcp.tool(name=tool_name("dom_type", prefix))
def dom_type(
selector: str,
text: str,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, bool]:
"""Type text into the first element matching a CSS selector."""
_client(client_factory, browser, remote, key).dom.type(selector, text)
return {"typed": True}
@mcp.tool(name=tool_name("screenshot", prefix), structured_output=False)
def screenshot(
tab_id: int | None = None,
format: Literal["png", "jpeg"] = "png",
quality: int | None = None,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> Any:
"""Capture the visible area of the active or specified tab as an image."""
data_url = _client(client_factory, browser, remote, key).tabs.screenshot(
tab_id, format=format, quality=quality
)
data, actual_format = _screenshot_bytes(data_url)
return Image(data=data, format=actual_format)
return mcp
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the stateless browser-cli MCP server.")
parser.add_argument("--transport", choices=("stdio", "streamable-http"), default="stdio")
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (streamable-http only).")
parser.add_argument("--port", type=int, default=8000, help="HTTP bind port (streamable-http only).")
parser.add_argument("--path", default="/mcp", help="MCP endpoint path (streamable-http only).")
return parser
def main(argv: list[str] | None = None) -> None:
"""Run over stdio, or stateless Streamable HTTP when explicitly selected."""
args = _parser().parse_args(argv)
mcp = create_server()
if args.transport == "stdio":
mcp.run()
return
if args.host not in {"127.0.0.1", "localhost", "::1"}:
raise SystemExit(
"Refusing to expose the unauthenticated MCP server beyond localhost. "
"Use browser-cli's authenticated remote transport from a local MCP server instead."
)
mcp.run(
transport="streamable-http",
host=args.host,
port=args.port,
streamable_http_path=args.path,
stateless_http=True,
json_response=True,
)
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
"""Tab targeting for the MCP surface.
MCP callers pay a full round trip for every extra tool call, so tools that act
on a tab accept an optional ``tab_id`` and fall back to the browser's current
active tab. Resolution happens here rather than by forwarding ``None`` into the
SDK, so the acting tool always knows which tab it touched and can report it.
"""
from __future__ import annotations
from browser_cli import BrowserCLI
def resolve_tab_id(client: BrowserCLI, tab_id: int | None) -> int:
"""Return *tab_id*, or the ID of the currently active tab when it is ``None``."""
if tab_id is not None:
return tab_id
return client.tabs.active().id
+23
View File
@@ -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)
+26
View File
@@ -22,6 +22,32 @@ 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 (
+30 -1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.16.4", "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": {
+10
View File
@@ -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,
+15 -11
View File
@@ -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,6 +15,19 @@ export class PerfCommands extends CommandGroup {
"jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a), "jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a),
}; };
private jobSummary(job: Job) {
return {
id: job.id,
command: job.command,
status: job.status,
phase: job.phase,
current: job.current,
total: job.total,
percent: job.percent,
cancelRequested: job.cancelRequested,
};
}
private async perfStatus() { private async perfStatus() {
const profile = await getPerformanceProfile(); const profile = await getPerformanceProfile();
const audible = await hasAudibleTabs(); const audible = await hasAudibleTabs();
@@ -23,16 +36,7 @@ export class PerfCommands extends CommandGroup {
performanceProfile: profile, performanceProfile: profile,
audible, audible,
throttle, throttle,
jobs: this.ctx.jobs.list().map(job => ({ jobs: this.ctx.jobs.list().map(job => this.jobSummary(job)),
id: job.id,
command: job.command,
status: job.status,
phase: job.phase,
current: job.current,
total: job.total,
percent: job.percent,
cancelRequested: job.cancelRequested,
})),
}; };
} }
} }
+29 -2
View File
@@ -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 };
} }
+16 -1
View File
@@ -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();
+69
View File
@@ -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' });
});
+5 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.16.4" 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
View File
@@ -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
View File
@@ -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,
+19 -17
View File
@@ -356,7 +356,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
assert targets[0].display_group == "browser-host.example" 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."""
@@ -681,7 +681,8 @@ def test_collect_browser_clients_with_explicit_remote_lists_all_targets(monkeypa
rows = collect_browser_clients(remote="browser-host.example:8765", registry_path=tmp_path / "missing-registry.json") rows = collect_browser_clients(remote="browser-host.example:8765", registry_path=tmp_path / "missing-registry.json")
assert [row["profile"] for row in rows] == ["browser-host.example:main", "browser-host.example:work"] 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"] assert [row["name"] for row in rows] == ["Chrome", "Firefox"]
def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target(monkeypatch, tmp_path): def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target(monkeypatch, tmp_path):
@@ -696,7 +697,8 @@ def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target
rows = collect_browser_clients(remote="browser-host.example:8765", browser_alias="work", registry_path=tmp_path / "missing-registry.json") 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] == ["browser-host.example:work"] 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."""
+241
View File
@@ -0,0 +1,241 @@
"""Tests for the optional stateless MCP adapter."""
from __future__ import annotations
import base64
from types import SimpleNamespace
import pytest
pytest.importorskip("mcp")
from mcp import Client
from mcp.types import ImageContent
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.naming import resolve_tool_prefix
from browser_cli.mcp.server import _screenshot_bytes, create_server, main
from browser_cli.models import Tab
class FakeClient:
instances: list["FakeClient"] = []
def __init__(self, browser=None, remote=None, key=None):
self.target = (browser, remote, key)
self.calls: list[tuple] = []
self.tabs = SimpleNamespace(
list=self.tabs_list,
open=self.tabs_open,
close=self.tabs_close,
active=self.tabs_active,
status=self.tabs_status,
screenshot=self.tabs_screenshot,
)
self.nav = SimpleNamespace(to=self.navigate_to)
self.page = SimpleNamespace(info=self.page_info)
self.extract = SimpleNamespace(text=self.extract_text, markdown=self.extract_markdown)
self.dom = SimpleNamespace(query=self.dom_query, click=self.dom_click, type=self.dom_type)
self.instances.append(self)
def tabs_list(self):
return [{"id": 7, "title": "Example", "url": "https://example.com"}]
def tabs_open(self, url, **kwargs):
self.calls.append(("open", url, kwargs))
return {"id": 8, "title": "Opened", "url": url}
def tabs_close(self, tab_id):
self.calls.append(("close", tab_id))
return 1
def tabs_active(self):
self.calls.append(("active",))
return SimpleNamespace(id=7)
def tabs_status(self, tab_id):
self.calls.append(("status", tab_id))
return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
def tabs_screenshot(self, tab_id, **kwargs):
self.calls.append(("screenshot", tab_id, kwargs))
return "data:image/png;base64," + base64.b64encode(b"png-data").decode()
def navigate_to(self, tab_id, url):
self.calls.append(("navigate", tab_id, url))
def page_info(self):
return {"title": "Example", "url": "https://example.com"}
def extract_text(self):
return "Page text"
def extract_markdown(self, selector=None):
return f"# Page {selector or ''}".rstrip()
def dom_query(self, selector):
return [{"tag": "button", "selector": selector}]
def dom_click(self, selector):
self.calls.append(("click", selector))
def dom_type(self, selector, text):
self.calls.append(("type", selector, text))
@pytest.fixture(autouse=True)
def clear_instances():
FakeClient.instances.clear()
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def client():
server = create_server(client_factory=FakeClient)
async with Client(server, raise_exceptions=True) as connected:
yield connected
@pytest.mark.anyio
async def test_each_tool_call_constructs_a_fresh_targeted_client(client, monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
first = await client.call_tool("browser_tabs_list", {
"browser": "work",
"remote": "browser-host.example:443",
"key": "agent",
})
second = await client.call_tool("browser_page_info", {})
assert first.structured_content == {
"result": [{"id": 7, "title": "Example", "url": "https://example.com"}]
}
assert second.structured_content == {
"title": "Example",
"url": "https://example.com",
}
assert len(FakeClient.instances) == 2
assert FakeClient.instances[0].target == ("work", "browser-host.example:443", "agent")
assert FakeClient.instances[1].target == (None, None, None)
@pytest.mark.anyio
async def test_environment_pins_all_calls_to_one_browser(client, monkeypatch):
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
await client.call_tool("browser_tabs_list", {})
assert FakeClient.instances[-1].target == ("testing", None, None)
def test_tool_prefix_defaults_to_browser_and_is_configurable():
assert resolve_tool_prefix({}) == "browser_"
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": ""}) == ""
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web"}) == "web_"
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web_"}) == "web_"
with pytest.raises(ValueError):
resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "9-bad prefix"})
@pytest.mark.anyio
async def test_hosts_that_namespace_tools_can_drop_the_builtin_prefix():
server = create_server(client_factory=FakeClient, tool_prefix="")
async with Client(server, raise_exceptions=True) as connected:
names = {tool.name for tool in (await connected.list_tools()).tools}
assert "tabs_list" in names
assert not any(name.startswith("browser_") for name in names)
@pytest.mark.anyio
async def test_explicit_target_overrides_environment_pin(client, monkeypatch):
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
await client.call_tool("browser_tabs_list", {"browser": "main"})
assert FakeClient.instances[-1].target == ("main", None, None)
@pytest.mark.anyio
async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
opened = await client.call_tool("browser_tabs_open", {
"url": "https://example.com",
"wait": True,
"focus": True,
})
navigated = await client.call_tool("browser_navigate", {
"tab_id": 8,
"url": "https://example.com/next",
})
clicked = await client.call_tool("browser_dom_click", {"selector": "#submit"})
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel"})
assert opened.structured_content == {
"id": 8, "title": "Opened", "url": "https://example.com"
}
assert navigated.structured_content["id"] == 8
assert clicked.structured_content["url"] == "https://example.com"
assert typed.structured_content == {"typed": True}
assert FakeClient.instances[0].calls == [
("open", "https://example.com", {
"wait": True, "timeout": 30.0, "background": False, "focus": True
})
]
assert FakeClient.instances[1].calls == [
("navigate", 8, "https://example.com/next"), ("status", 8)
]
@pytest.mark.anyio
async def test_tab_tools_default_to_the_active_tab(client):
navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"})
closed = await client.call_tool("browser_tabs_close", {})
assert navigated.structured_content["id"] == 7
assert closed.structured_content == {"closed": 1, "tab_id": 7}
assert FakeClient.instances[0].calls == [
("active",), ("navigate", 7, "https://example.com/next"), ("status", 7)
]
assert FakeClient.instances[1].calls == [("active",), ("close", 7)]
@pytest.mark.anyio
async def test_screenshot_returns_image_content(client):
result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"})
assert result.structured_content is None
assert len(result.content) == 1
assert isinstance(result.content[0], ImageContent)
assert result.content[0].data == base64.b64encode(b"png-data").decode()
assert result.content[0].mime_type == "image/png"
def test_screenshot_decoder_rejects_non_data_url():
with pytest.raises(ValueError, match="invalid screenshot"):
_screenshot_bytes("not-an-image")
def test_sdk_dataclass_serialization_does_not_traverse_bound_client():
tab = Tab(id=7, window_id=1, active=True, title="Example")
tab._browser = SimpleNamespace(secret="must not be serialized")
result = structured(tab)
assert result["id"] == 7
assert result["window_id"] == 1
assert "_browser" not in result
def test_http_server_refuses_non_local_bind(monkeypatch):
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: None))
with pytest.raises(SystemExit, match="Refusing to expose"):
main(["--transport", "streamable-http", "--host", "0.0.0.0"])
def test_http_server_enables_stateless_json_transport(monkeypatch):
calls = []
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: calls.append(kwargs)))
main(["--transport", "streamable-http", "--port", "9000", "--path", "/browser"])
assert calls == [{
"transport": "streamable-http",
"host": "127.0.0.1",
"port": 9000,
"streamable_http_path": "/browser",
"stateless_http": True,
"json_response": True,
}]
+33
View File
@@ -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",
+17
View File
@@ -23,6 +23,23 @@ def test_save_remote_with_key_and_remove(monkeypatch, tmp_path):
assert remote_registry.remove_remote("browser-host.example:443") is True assert remote_registry.remove_remote("browser-host.example:443") is True
assert remote_registry.load_remotes() == {} 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): def test_remote_add_list_remove_cli(monkeypatch, tmp_path):
path = tmp_path / "remotes.json" path = tmp_path / "remotes.json"
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path) monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
+17
View File
@@ -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():
Generated
+891 -175
View File
File diff suppressed because it is too large Load Diff