From 6785b9f70cb97887e24ec87f5052b668dba487dc Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sat, 25 Apr 2026 18:33:59 +0200 Subject: [PATCH] feat(serve): add remote browser control over TCP with token auth Exposes a local browser over a TCP socket so remote machines can control it using the same CLI and Python API. Token auth (auto-generated via secrets.token_urlsafe) is on by default; --no-auth disables it. Profile routing via _route message field lets clients target specific browser instances on the remote host. BROWSER_CLI_PROFILE is forwarded automatically so --browser flag works transparently over remote. - browser-cli serve [--host] [--port] [--token] [--no-auth] - browser-cli --remote HOST:PORT --token TOKEN - BrowserCLI(remote="host:port", token="...").tabs_list() --- browser_cli/__init__.py | 10 ++- browser_cli/cli.py | 16 +++- browser_cli/client.py | 28 ++++++- browser_cli/commands/serve.py | 153 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_api.py | 70 ++++++++-------- uv.lock | 2 +- 7 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 browser_cli/commands/serve.py diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index a2447a4..a977056 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -33,16 +33,22 @@ class BrowserCounts: class BrowserCLI: - def __init__(self, browser: str | None = None): + def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None): """ Args: browser: Profile alias to target. Required when multiple browser instances are active. Equivalent to ``--browser`` on the CLI. + remote: Connect to a remote browser exposed via ``browser-cli serve``. + Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``). + When set, ``browser`` is ignored. + token: Auth token for the remote serve instance. """ self._browser = browser + self._remote = remote + self._token = token def _cmd(self, command: str, args: dict | None = None): - return send_command(command, args, profile=self._browser) + return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token) def _multi_browser_targets(self): if self._browser is not None: diff --git a/browser_cli/cli.py b/browser_cli/cli.py index bf38d2b..d865e1e 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -24,6 +24,7 @@ from browser_cli.commands.search import search_group from browser_cli.commands.page import page_group from browser_cli.commands.storage import storage_group from browser_cli.commands.cookies import cookies_group +from browser_cli.commands.serve import cmd_serve from browser_cli.client import ( send_command, BrowserNotConnected, @@ -164,14 +165,26 @@ def _print_version(ctx, param, value): "--browser", default=None, metavar="ALIAS", help="Browser profile alias to target (required when multiple browsers are active).", ) +@click.option( + "--remote", default=None, metavar="HOST:PORT", + help="Connect to a remote browser exposed via 'browser-cli serve'.", +) +@click.option( + "--token", default=None, metavar="TOKEN", + help="Auth token for the remote browser-cli serve instance.", +) @click.pass_context -def main(ctx, browser): +def main(ctx, browser, remote, token): """Control your running browser from the terminal via a Chrome extension.""" ctx.ensure_object(dict) ctx.obj["browser"] = browser ctx.obj["browser_explicit"] = browser is not None if browser: os.environ["BROWSER_CLI_PROFILE"] = browser + if remote: + os.environ["BROWSER_CLI_REMOTE"] = remote + if token: + os.environ["BROWSER_CLI_TOKEN"] = token # ── Sub-command groups ───────────────────────────────────────────────────────── @@ -186,6 +199,7 @@ main.add_command(search_group) main.add_command(page_group) main.add_command(storage_group) main.add_command(cookies_group) +main.add_command(cmd_serve) # ── clients ──────────────────────────────────────────────────────────────────── diff --git a/browser_cli/client.py b/browser_cli/client.py index 6c8467d..3aef0a5 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -98,28 +98,50 @@ def _resolve_socket(profile: str | None = None) -> str: ) -def send_command(command: str, args: dict | None = None, profile: str | None = None) -> Any: +def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None) -> Any: """Send a command to the browser and return the response data.""" - sock_path = _resolve_socket(profile) + remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE") + resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") msg = { "id": str(uuid.uuid4()), "command": command, "args": args or {}, } + if remote_endpoint: + if resolved_token: + msg["token"] = resolved_token + route_profile = profile or os.environ.get("BROWSER_CLI_PROFILE") + if route_profile: + msg["_route"] = route_profile payload = json.dumps(msg).encode("utf-8") framed = struct.pack(" bytes: + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise ConnectionError("Connection closed") + buf += chunk + return buf + +def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None: + ts = datetime.now().strftime("%H:%M:%S") + addr_str = f"{addr[0]}:{addr[1]}" + profile_str = f"[dim]{profile}[/dim] " if profile else "" + if error: + console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}") + else: + console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]") + +def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None) -> None: + from browser_cli.client import _resolve_socket, BrowserNotConnected + from browser_cli.platform import is_windows + + try: + header = _recv_exact(client_sock, 4) + msg_len = struct.unpack(" None: + err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode() + try: + client_sock.sendall(struct.pack(" None: + with client_sock: + _proxy_request(client_sock, addr, profile, server_token) + +@click.command("serve") +@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.") +@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.") +@click.option("--token", default=None, metavar="TOKEN", help="Auth token (auto-generated if omitted).") +@click.option("--no-auth", is_flag=True, default=False, help="Disable token authentication.") +@click.pass_context +def cmd_serve(ctx, host, port, token, no_auth): + """Expose this browser over TCP so remote hosts can control it.""" + profile = ctx.obj.get("browser") if ctx.obj else None + + if host in ("0.0.0.0", "::"): + console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.") + + if no_auth: + server_token = None + else: + server_token = token or secrets.token_urlsafe(32) + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + server.bind((host, port)) + except OSError as e: + console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}") + sys.exit(1) + server.listen(16) + + browser_hint = f" (browser: {profile})" if profile else "" + console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]") + + if server_token: + console.print(f" Token: [bold yellow]{server_token}[/bold yellow]") + console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]") + console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\", token=\"{server_token}\").tabs_list()[/dim]") + else: + console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]") + console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]") + console.print("[yellow] Auth disabled (--no-auth)[/yellow]") + + console.print("Ctrl-C to stop.\n") + + try: + while True: + conn, addr = server.accept() + threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token), daemon=True).start() + except KeyboardInterrupt: + console.print("[yellow]Stopped.[/yellow]") + finally: + server.close() diff --git a/pyproject.toml b/pyproject.toml index 2a8a56a..168111f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.7.1" +version = "0.8.0" description = "Control your real running browser from the terminal via a browser extension" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index d2d05dd..30760be 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -122,7 +122,7 @@ class TestNavigation: mock_send.assert_called_once_with( "navigate.open", {"url": "https://example.com", "background": False, "window": None, "group": None}, - profile=None, + profile=None, remote=None, token=None, ) def test_open_background(self, b, mock_send): @@ -136,33 +136,33 @@ class TestNavigation: def test_reload(self, b, mock_send): b.reload(tab_id=5) - mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None) + mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, token=None) def test_hard_reload(self, b, mock_send): b.hard_reload(tab_id=7) - mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None) + mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, token=None) def test_back(self, b, mock_send): b.back(tab_id=3) - mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None) + mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, token=None) def test_forward(self, b, mock_send): b.forward(tab_id=3) - mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None) + mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, token=None) def test_focus_url(self, b, mock_send): b.focus_url("github.com") - mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None) + mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, token=None) def test_navigate_tab(self, b, mock_send): b.navigate_tab(5, "https://example.com") mock_send.assert_called_once_with( - "navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None + "navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None ) def test_profile_forwarded(self, b_profile, mock_send): b_profile.reload() - mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave") + mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None) # ── Search ──────────────────────────────────────────────────────────────────── @@ -195,12 +195,12 @@ class TestExtract: result = b.extract_markdown() assert result == "# Title" - mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None) + mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, token=None) def test_extract_markdown_selector(self, b, mock_send): b.extract_markdown("article") - mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None) + mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, token=None) # ── Tabs ────────────────────────────────────────────────────────────────────── @@ -235,7 +235,7 @@ class TestTabs: mock_send.assert_called_once_with( "tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, - profile=None, + profile=None, remote=None, token=None, ) def test_tabs_move(self, b, mock_send): @@ -243,19 +243,19 @@ class TestTabs: mock_send.assert_called_once_with( "tabs.move", {"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None}, - profile=None, + profile=None, remote=None, token=None, ) def test_tabs_active(self, b, mock_send): b.tabs_active(10) - mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None) + mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None) def test_window_active_tab(self, b, mock_send): mock_send.return_value = TAB_DATA tab = b.window_active_tab(1) assert isinstance(tab, Tab) assert tab.id == 10 - mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None) + mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None) def test_window_active_tab_missing_raises(self, b, mock_send): mock_send.return_value = None @@ -308,7 +308,7 @@ class TestTabs: assert mock_send.call_args_list == [ call("tabs.list", {}, profile="default"), call("tabs.list", {}, profile="work"), - call("tabs.close", {"tabId": 11}, profile="work"), + call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None), ] def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send): @@ -351,7 +351,7 @@ class TestTabs: def test_tabs_sort(self, b, mock_send): b.tabs_sort(by="title") - mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None) + mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, token=None) def test_tabs_merge_windows(self, b, mock_send): mock_send.return_value = {"moved": 4} @@ -384,7 +384,7 @@ class TestGroups: mock_send.return_value = [TAB_DATA] tabs = b.group_tabs(42) assert isinstance(tabs[0], Tab) - mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None) + mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None) def test_group_count(self, b, mock_send): mock_send.return_value = 7 @@ -412,7 +412,7 @@ class TestGroups: assert mock_send.call_args_list == [ call("group.list", {}, profile="default"), call("group.list", {}, profile="work"), - call("group.close", {"groupId": 99}, profile="work"), + call("group.close", {"groupId": 99}, profile="work", remote=None, token=None), ] def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send): @@ -435,7 +435,7 @@ class TestGroups: def test_group_close(self, b, mock_send): b.group_close(42) - mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None) + mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None) def test_group_create_dict_response(self, b, mock_send): mock_send.return_value = GROUP_DATA @@ -455,7 +455,7 @@ class TestGroups: tab_id = b.group_add_tab(42, "https://example.com") assert tab_id == 55 mock_send.assert_called_once_with( - "group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None + "group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, token=None ) def test_group_add_tab_non_dict_response(self, b, mock_send): @@ -465,7 +465,7 @@ class TestGroups: def test_group_move_forward(self, b, mock_send): b.group_move(42, forward=True) mock_send.assert_called_once_with( - "group.move", {"group": "42", "forward": True, "backward": False}, profile=None + "group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None ) @@ -495,7 +495,7 @@ class TestWindows: result = b.windows_open() assert result == {"id": 5} - mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None) + mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None) def test_windows_open_with_url(self, b, mock_send): mock_send.return_value = {"id": 9} @@ -503,7 +503,7 @@ class TestWindows: result = b.windows_open("https://example.com") assert result == {"id": 9} - mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None) + mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None) class TestSession: @@ -513,7 +513,7 @@ class TestSession: result = b.session_list() assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}] - mock_send.assert_called_once_with("session.list", {}, profile=None) + mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None) def test_session_list_multi_browser_adds_browser(self, b, mock_send): with patch( @@ -548,26 +548,26 @@ class TestTabModel: def test_close(self, tab, mock_send): tab.close() - mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None) + mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, token=None) def test_activate(self, tab, mock_send): tab.activate() - mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None) + mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None) def test_reload(self, tab, mock_send): tab.reload() - mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None) + mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None) def test_hard_reload(self, tab, mock_send): tab.hard_reload() - mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None) + mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, token=None) def test_move_forward(self, tab, mock_send): tab.move(forward=True) mock_send.assert_called_once_with( "tabs.move", {"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None}, - profile=None, + profile=None, remote=None, token=None, ) def test_move_to_group(self, tab, mock_send): @@ -577,12 +577,12 @@ class TestTabModel: def test_html(self, tab, mock_send): mock_send.return_value = "" assert tab.html() == "" - mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None) + mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, token=None) def test_open(self, tab, mock_send): tab.open("https://new.example.com") mock_send.assert_called_once_with( - "navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None + "navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None ) def test_open_background_changes_same_tab(self, tab, mock_send): @@ -590,7 +590,7 @@ class TestTabModel: mock_send.assert_called_once_with( "navigate.to", {"tabId": 10, "url": "https://new.example.com"}, - profile=None, + profile=None, remote=None, token=None, ) def test_unbound_raises(self): @@ -608,18 +608,18 @@ class TestGroupModel: def test_close(self, group, mock_send): group.close() - mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None) + mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None) def test_tabs(self, group, mock_send): mock_send.return_value = [TAB_DATA] tabs = group.tabs() assert isinstance(tabs[0], Tab) - mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None) + mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None) def test_move_forward(self, group, mock_send): group.move(forward=True) mock_send.assert_called_once_with( - "group.move", {"group": "42", "forward": True, "backward": False}, profile=None + "group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None ) def test_move_backward(self, group, mock_send): diff --git a/uv.lock b/uv.lock index 292a64f..0cde72e 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "browser-cli" -version = "0.7.1" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "click" },