Compare commits

...
4 Commits
Author SHA1 Message Date
daniel156161 0c005bc119 Default MCP tab tools to the active tab
Testing / remote-protocol-compat (0.15.0) (push) Successful in 49s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 47s
Testing / test (push) Successful in 59s
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:39:48 +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
25 changed files with 1872 additions and 215 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]"
```
To expose the conservative MCP tool surface, install the optional `mcp` extra:
```sh
uv tool install "real-browser-cli[mcp]"
```
To upgrade later:
```sh
@@ -141,6 +146,89 @@ browser-cli/
---
## Stateless MCP server
The optional MCP adapter exposes a small, typed subset of the Python SDK for
MCP hosts such as Claude Desktop, Claude Code, Cursor, or VS Code. It controls
the same real browser; it does not launch a headless browser or duplicate the
browser command implementation.
Install and run the local stdio server:
```sh
uv tool install "real-browser-cli[mcp]"
browser-cli-mcp
```
Example MCP host configuration:
```json
{
"mcpServers": {
"browser-cli": {
"command": "browser-cli-mcp"
}
}
}
```
The server is stateless at the MCP layer. Every tool call creates a fresh
`BrowserCLI` SDK client, and browser state remains in the real browser. Pass
`browser`, `remote`, and `key` on a tool call when a specific local profile or
authenticated browser-cli remote is required.
`browser_navigate`, `browser_tabs_close`, and `browser_screenshot` take an
optional `tab_id` and act on the active tab when it is omitted, so a caller
does not need a preceding `browser_tabs_list` round trip. `browser_tabs_close`
reports the tab it closed.
Available tools:
- `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close`
- `browser_navigate`, `browser_page_info`
- `browser_extract_text`, `browser_extract_markdown`
- `browser_dom_query`, `browser_dom_click`, `browser_dom_type`
- `browser_screenshot`
Generic JavaScript evaluation, raw browser commands, storage writes, and
session import are intentionally not exposed.
### Pinning one browser and naming tools
Set `BROWSER_CLI_PROFILE` to pin every call from an MCP server to one browser,
so tools do not have to pass `browser` and listing tools do not fan out across
all connected browsers:
```json
{
"mcpServers": {
"browser-cli-testing": {
"command": "browser-cli-mcp",
"env": {
"BROWSER_CLI_PROFILE": "testing",
"BROWSER_CLI_MCP_TOOL_PREFIX": ""
}
}
}
}
```
`BROWSER_CLI_REMOTE` and `BROWSER_CLI_KEY` pin an authenticated remote the same
way. Explicit `browser`, `remote`, and `key` tool arguments still win.
Tool names carry a `browser_` prefix by default so they stay unambiguous in
hosts that expose raw MCP tool names. Hosts that already prefix tools with the
server name produce stutter such as `browser_cli_testing_browser_tabs_list`;
setting `BROWSER_CLI_MCP_TOOL_PREFIX` to an empty string drops the built-in
prefix and yields `browser_cli_testing_tabs_list`. Any other value replaces the
prefix.
For local development and testing, Streamable HTTP is also available:
```sh
browser-cli-mcp --transport streamable-http --port 8000
# endpoint: http://127.0.0.1:8000/mcp
```
HTTP uses stateless JSON responses and intentionally refuses non-loopback bind
addresses because this MCP endpoint has no independent authentication. For a
browser on another machine, keep MCP local and pass an authenticated
browser-cli `remote` target to each tool call.
---
## CLI reference
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
+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))
return targets
def _cached_client_row(target: BrowserTarget) -> dict | None:
def _cached_client_row(target: BrowserTarget, *, scoped: bool = False) -> dict | None:
"""Build a clients row from a target's discovery data, skipping a roundtrip.
Returns None when the remote didn't advertise its version (older serve), so
callers fall back to an explicit ``clients.list`` query.
callers fall back to an explicit ``clients.list`` query. When *scoped* is
true, the caller already selected one remote host, so render profile-only
labels instead of adding a host group header.
"""
if target.version is None and target.extension_version is None:
return None
return {
"profile": target.display_name,
"profileGroup": target.display_group,
row = {
"profile": target.profile if scoped else target.display_name,
"name": target.browser_name or "",
"version": target.version or "",
"extensionVersion": target.extension_version or "",
}
if target.display_group and not scoped:
row["profileGroup"] = target.display_group
return row
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
rows = []
@@ -249,18 +253,17 @@ def collect_browser_clients(
if targets:
uncached = []
for target in targets:
cached = _cached_client_row(target)
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.display_name,
t.profile,
profile=t.profile,
remote=remote,
key=key,
profile_group=t.display_group,
))
for t in uncached
])
+2 -2
View File
@@ -5,8 +5,8 @@ import uuid
from typing import Any
from browser_cli import transport
from browser_cli.endpoints import _normalize_endpoint
from browser_cli.errors import BrowserNotConnected
from browser_cli.remote.registry import resolve_remote_endpoint
def base_message(command: str, args: dict | None) -> dict:
return {"id": str(uuid.uuid4()), "command": command, "args": args or {}}
@@ -14,7 +14,7 @@ def base_message(command: str, args: dict | None) -> dict:
def requested_target(profile: str | None, remote: str | None) -> tuple[str | None, str | None]:
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
return requested_profile, _normalize_endpoint(remote_endpoint) if remote_endpoint else None
return requested_profile, resolve_remote_endpoint(remote_endpoint) if remote_endpoint else None
def encode_payload(msg: dict) -> bytes:
return json.dumps(msg).encode("utf-8")
+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.
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
_MAX_PER_ENDPOINT = 8
_MAX_ENDPOINTS = 64
class PooledConnection:
__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
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:
"""Return a still-healthy connection to the pool for reuse."""
conn.last_used = time.monotonic()
with _LOCK:
if endpoint not in _POOL and len(_POOL) >= _MAX_ENDPOINTS:
_prune_endpoints_locked(conn.last_used)
bucket = _POOL.setdefault(endpoint, [])
if len(bucket) >= _MAX_PER_ENDPOINT:
_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.
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def resolve_remote_endpoint(endpoint: str | None) -> str | None:
"""Resolve a user-supplied remote alias to a remembered endpoint.
Domain-like remotes without an explicit port still default to :443 when no
matching remembered remote exists. If the user remembered exactly one
explicit-port remote for the same host (for example
``browser-host.example:8765``), use that endpoint so ``--remote
browser-host.example`` targets the stored service instead of assuming HTTPS.
"""
if not endpoint:
return None
normalized = _normalize_endpoint(endpoint)
host, sep, _port = normalized.rpartition(":")
if sep:
return normalized
remotes = load_remotes()
explicit_matches = []
for remote_endpoint in remotes:
remote_host, remote_sep, remote_port = remote_endpoint.rpartition(":")
if remote_sep and remote_host == normalized and remote_port != "443":
explicit_matches.append(remote_endpoint)
if len(explicit_matches) == 1:
return explicit_matches[0]
return normalized
def is_valid_key_spec(value: str) -> bool:
"""Return True for 'agent', 'agent:<selector>', or a plausible key file path."""
return value == "agent" or value.startswith("agent:") or (
+30 -1
View File
@@ -70,19 +70,48 @@ class RateLimiter:
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
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.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._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:
if self.rate <= 0:
return True
now = time.monotonic()
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 = min(self.capacity, tokens + (now - last) * self.rate)
if tokens < 1.0:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.16.4",
"version": "0.16.6",
"description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": {
"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.
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
// dead tab), finalize the job as an error so its persist interval stops instead
// of writing to api.storage.local every second forever.
@@ -77,6 +82,11 @@ export class JobManager {
}
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 job: Job = {
id: jobId,
+14 -10
View File
@@ -1,7 +1,7 @@
import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core';
import { CommandGroup } 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
// 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),
};
private async perfStatus() {
const profile = await getPerformanceProfile();
const audible = await hasAudibleTabs();
const throttle = await getLargeOperationThrottle(0, "auto");
private jobSummary(job: Job) {
return {
performanceProfile: profile,
audible,
throttle,
jobs: this.ctx.jobs.list().map(job => ({
id: job.id,
command: job.command,
status: job.status,
@@ -32,7 +25,18 @@ export class PerfCommands extends CommandGroup {
total: job.total,
percent: job.percent,
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)),
};
}
}
+29 -2
View File
@@ -14,9 +14,31 @@ export class WindowsCommands extends CommandGroup {
"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() {
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 => ({
id: w.id,
alias: aliases[w.id] || null,
@@ -27,7 +49,7 @@ export class WindowsCommands extends CommandGroup {
}
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
const aliases = await getAliases();
const aliases = await this.pruneAliases();
aliases[windowId] = name;
await api.storage.local.set({ windowAliases: aliases });
return { windowId, name };
@@ -35,6 +57,11 @@ export class WindowsCommands extends CommandGroup {
private async windowsClose({ windowId }: WindowsCloseArgs) {
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 };
}
+16 -1
View File
@@ -1,7 +1,7 @@
// @ts-nocheck
import { test, mock } from 'node:test';
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';
// 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();
});
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 () => {
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
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]
name = "real-browser-cli"
version = "0.16.4"
version = "0.16.6"
description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md"
license = { file = "LICENSE" }
@@ -22,9 +22,13 @@ Issues = "https://git.yiprawr.dev/Automatisation/browser-cli/issues"
[project.optional-dependencies]
# Better/faster remote response compression than the stdlib zlib/gzip fallback.
fast = ["zstandard>=0.22"]
mcp = [
"mcp>=2,<3",
]
[project.scripts]
browser-cli = "browser_cli.cli:main"
browser-cli-mcp = "browser_cli.mcp.server:main"
[dependency-groups]
dev = [
+34 -11
View File
@@ -294,29 +294,52 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
assert "0.8.2" in result.output
def test_clients_remote_uses_remote_endpoint_without_local_registry():
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "clients.list"
assert profile is None
assert remote == "127.0.0.1:8765"
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
target = BrowserTarget(
profile="work",
display_name="127.0.0.1:work",
socket_path="",
remote="127.0.0.1:8765",
browser_name="Chrome",
display_group="127.0.0.1",
version="1",
extension_version="2.3.4",
)
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
), patch("browser_cli.client.core.send_command", side_effect=fake_send_command) as send_command:
), patch("browser_cli.client.core.remote_browser_targets", return_value=[target]), patch(
"browser_cli.client.core.send_command"
) as send_command:
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
assert result.exit_code == 0
send_command.assert_called_once()
assert "remote" in result.output
send_command.assert_not_called()
assert "work" in result.output
assert "127.0.0.1" not in result.output
assert "Chrome" in result.output
assert "2.3.4" in result.output
def test_clients_remote_respects_global_browser_route():
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.client.core.send_command", return_value=[]) as send_command:
target = BrowserTarget(
profile="work",
display_name="127.0.0.1:work",
socket_path="",
remote="127.0.0.1:8765",
browser_name="Chrome",
display_group="127.0.0.1",
version="1",
extension_version="2.3.4",
)
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.client.core.remote_browser_targets", return_value=[target]
), patch("browser_cli.client.core.send_command") as send_command:
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
assert result.exit_code == 1
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
assert result.exit_code == 0
send_command.assert_not_called()
assert "work" in result.output
assert "127.0.0.1" not in result.output
def test_clients_browser_alias_resolves_to_remote():
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
+4 -2
View File
@@ -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")
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"]
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")
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):
"""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()
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():
msg = {
"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.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)
+17
View File
@@ -181,6 +181,23 @@ def test_rate_limiter_is_per_key():
assert limiter.allow("a") 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 ────────────────────────────────────────────────────────────────
def test_effective_policy_prefers_per_key_override():
Generated
+891 -175
View File
File diff suppressed because it is too large Load Diff