diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 36e5f81..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "servicelink"] - path = servicelink - url = git@git.yiprawr.dev:submodules/servicelink.git diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..5c44e4d --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,23 @@ +# Privacy Policy for browser-cli +Last updated: 2026-06-14 + +browser-cli does not collect, transmit, sell, or share user data with the developer or any third party. + +browser-cli is a local browser automation tool. The browser extension communicates with the locally installed browser-cli native messaging host so the user can control their own browser through the command line or Python SDK. + +## Local data access +Depending on the command explicitly run by the user, browser-cli may locally access browser data such as: +- tab URLs, titles, status, and window or tab group information +- page content, links, images, HTML, text, screenshots, or DOM data +- cookies, local storage, session storage, and saved browser-cli session data + +This access happens only to perform the command requested by the user. The data stays on the user's device unless the user explicitly configures browser-cli to connect to another machine they control. + +## Remote control mode +browser-cli includes an optional remote control mode. If the user enables this mode, command data may be transmitted between the user's configured browser-cli client and server endpoints. This is user-configured infrastructure. The developer does not receive or operate these endpoints. + +## No analytics or tracking +browser-cli does not use analytics, telemetry, advertising, behavioral tracking, or remote code. The extension does not send data to the developer. + +## Contact +For privacy questions or security reports, please open an issue in the project repository or contact the project maintainer through the repository hosting platform. diff --git a/browser_cli/cli.py b/browser_cli/cli.py index f8aceff..567932f 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -23,7 +23,6 @@ from browser_cli.commands.storage import storage_group from browser_cli.commands.perf import perf_group from browser_cli.commands.extension import extension_group from browser_cli.commands.serve import cmd_serve -from browser_cli.commands.link_serve import cmd_link_serve from browser_cli.commands.auth import auth_group from browser_cli.commands.clients import clients_group from browser_cli.commands.completion import cmd_completion @@ -128,7 +127,6 @@ main.add_command(storage_group) main.add_command(perf_group) main.add_command(extension_group) main.add_command(cmd_serve) -main.add_command(cmd_link_serve) main.add_command(clients_group) main.add_command(cmd_completion) main.add_command(cmd_install) diff --git a/browser_cli/commands/link_serve.py b/browser_cli/commands/link_serve.py deleted file mode 100644 index d3099d2..0000000 --- a/browser_cli/commands/link_serve.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Expose this browser over a ServiceLink HTTP /rpc endpoint. - -This lets the other nodes (picoshare, website) drive the browser through the -shared servicelink envelope, reusing browser-cli's own wire commands verbatim: -a link method like `tabs.list` forwards straight to `send_command_async`. - -It is separate from `serve` (the Ed25519/TCP remote-control daemon); use this -when you want a node in the mesh to call the browser with a bearer token. - -servicelink (and its httpx dependency) is imported lazily inside the command so -that a missing optional dependency never breaks the rest of the CLI. -""" -import asyncio -import hmac -import sys -from pathlib import Path - -import click - -from browser_cli.client.core import send_command_async -from browser_cli.errors import BrowserNotConnected - -# Curated set of browser commands exposed over the mesh. Method name == command. -EXPOSED_COMMANDS = [ - "tabs.list", "tabs.open", "tabs.close", "tabs.active", "tabs.query", - "nav.open", "nav.reload", "nav.back", "nav.forward", - "dom.query", "dom.text", "dom.attr", "dom.exists", "dom.click", - "extract.links", "extract.images", "extract.text", "extract.markdown", "extract.html", - "page.info", - "session.save", "session.load", "session.list", -] - -def _import_servicelink(): - """Import servicelink lazily; the flat submodule sits at the repo root.""" - repo_root = Path(__file__).resolve().parents[2] - if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - try: - import servicelink - return servicelink - except ImportError as exc: - raise click.ClickException( - "servicelink could not be imported. Initialise the submodule " - "(`git submodule update --init`) and ensure httpx is installed." - ) from exc - -def _build_router(sl, profile): - router = sl.Router("browser-cli") - - def make_handler(command): - async def handler(params, ctx): - try: - return await send_command_async(command, params or None, profile=profile) - except BrowserNotConnected as exc: - raise sl.Unavailable(f"browser not connected: {exc}") - except (RuntimeError, ConnectionError) as exc: - raise sl.LinkError(str(exc)) - return handler - - for command in EXPOSED_COMMANDS: - router.register(command, make_handler(command)) - return router - -def _make_verifier(sl, token): - if not token: - return None - - async def verify(authorization, request): - presented = (authorization or "").split(" ", 1)[-1].strip() - # Constant-time compare so a wrong token can't be timed out character by character. - if not presented or not hmac.compare_digest(presented, token): - raise sl.Unauthorized("invalid or missing token") - return sl.Principal(subject="mesh", scopes=frozenset({"all", "mesh"})) - - return verify - -def _http_response(status, body, content_type): - head = ( - f"HTTP/1.1 {status}\r\n" - f"Content-Type: {content_type}\r\n" - f"Content-Length: {len(body)}\r\n" - "Connection: close\r\n\r\n" - ).encode("latin-1") - return head + body - -async def _handle_connection(sl, router, verify, reader, writer): - try: - request_line = await reader.readline() - if not request_line: - return - headers = {} - while True: - line = await reader.readline() - if line in (b"\r\n", b"\n", b""): - break - key, _, value = line.decode("latin-1").partition(":") - headers[key.strip().lower()] = value.strip() - length = int(headers.get("content-length", "0") or "0") - body = await reader.readexactly(length) if length else b"" - - if not request_line.upper().startswith(b"POST"): - writer.write(_http_response(405, b'{"error":"only POST /rpc is supported"}', "application/json")) - else: - status, payload, content_type = await sl.handle_envelope( - router, - body, - authorization=headers.get("authorization"), - verify=verify, - content_type=headers.get("content-type", "application/json"), - ) - writer.write(_http_response(status, payload, content_type)) - await writer.drain() - except Exception: # noqa: BLE001 - never let one connection kill the server - pass - finally: - writer.close() - -async def _serve(sl, host, port, profile, token): - router = _build_router(sl, profile) - verify = _make_verifier(sl, token) - server = await asyncio.start_server( - lambda r, w: _handle_connection(sl, router, verify, r, w), host, port - ) - click.echo(f"servicelink browser node listening on http://{host}:{port}/rpc") - async with server: - await server.serve_forever() - -_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} - -@click.command("link-serve") -@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.") -@click.option("--port", default=8770, show_default=True, type=int, help="HTTP port for /rpc.") -@click.option("--token", default=None, metavar="SECRET", - help="Shared bearer token required from callers (sent as 'Authorization: Bearer ...').") -@click.option("--insecure", is_flag=True, default=False, - help="Run with NO token. Grants full browser control to anyone who can reach the port.") -@click.pass_context -def cmd_link_serve(ctx, host, port, token, insecure): - """Serve this browser to the ServiceLink mesh over HTTP /rpc. - - Exposes the running browser (open/scrape pages, read storage), so - a token is required by default. Bind to loopback and keep the port off the - public network. - """ - if not token and not insecure: - raise click.ClickException( - "Refusing to start without --token (this endpoint can control your browser " - "and read page/storage data). Pass --insecure to override on a trusted host." - ) - if host not in _LOOPBACK_HOSTS: - click.echo( - f"WARNING: binding to {host} (not loopback). Anyone who can reach this " - "address may control the browser; ensure it is firewalled.", - err=True, - ) - if insecure and not token: - click.echo("WARNING: --insecure set; the endpoint is UNAUTHENTICATED.", err=True) - sl = _import_servicelink() - profile = ctx.obj.get("browser") if ctx.obj else None - try: - asyncio.run(_serve(sl, host, port, profile, token)) - except KeyboardInterrupt: - pass diff --git a/browser_cli/commands/serve.py b/browser_cli/commands/serve.py index 64611dc..268a1b5 100644 --- a/browser_cli/commands/serve.py +++ b/browser_cli/commands/serve.py @@ -45,32 +45,9 @@ __all__ = [ default=False, help="Disable response compression / msgpack even for clients that support it.", ) -@click.option( - "--rpc", - is_flag=True, - default=False, - help="Also expose a ServiceLink HTTP /rpc endpoint (for mesh nodes) in the same process.", -) -@click.option("--rpc-port", default=8770, show_default=True, type=int, help="Port for the /rpc endpoint (with --rpc).") -@click.option( - "--rpc-token", - default=None, - metavar="SECRET", - help="Bearer token required on /rpc (with --rpc).", -) -@click.option( - "--rpc-insecure", - is_flag=True, - default=False, - help="Allow --rpc with no token (DANGEROUS: full browser control to anyone who can reach the port).", -) @click.pass_context -def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_port, rpc_token, rpc_insecure): - """Expose this browser over TCP so remote hosts can control it. - - With --rpc, additionally serve the ServiceLink mesh over HTTP /rpc on - --rpc-port, so the native TCP protocol and the node mesh share one daemon. - """ +def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress): + """Expose this browser over TCP so remote hosts can control it.""" profile = ctx.obj.get("browser") if ctx.obj else None compress = not no_compress @@ -84,40 +61,16 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_po if auth_keys_path is False: sys.exit(1) - if rpc and not rpc_token and not rpc_insecure: - console.print( - "[red]Error:[/red] --rpc requires --rpc-token (this endpoint can control your " - "browser and read page/storage data). Use --rpc-insecure to override on a trusted host." - ) - sys.exit(1) - _print_startup(host, port, profile, auth_keys_path, compress) - if rpc: - console.print(f" Mesh: [green]ServiceLink HTTP[/green] [cyan]{host}:{rpc_port}/rpc[/cyan]") - if not rpc_token: - console.print("[yellow] /rpc auth disabled (--rpc-insecure)[/yellow]") try: - if rpc: - asyncio.run(_serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token)) - else: - asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress)) + asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress)) except OSError as e: console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}") sys.exit(1) except KeyboardInterrupt: console.print("[yellow]Stopped.[/yellow]") -async def _serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token): - """Run the native TCP server and the ServiceLink HTTP /rpc server together.""" - from browser_cli.commands import link_serve - - sl = link_serve._import_servicelink() - await asyncio.gather( - _serve_async(host, port, profile, auth_keys_path, compress), - link_serve._serve(sl, host, rpc_port, profile, rpc_token), - ) - def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool: if auth_keys_file: from browser_cli.auth import load_authorized_keys diff --git a/extension/icon.svg b/extension/icon.svg index ba5f28d..76705f9 100644 --- a/extension/icon.svg +++ b/extension/icon.svg @@ -1,28 +1,31 @@ browser-cli icon - + - + - - - - - - + + + - - - + + + + + - - - + + + + + + + diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png index 5926058..b527d38 100644 Binary files a/extension/icons/icon-128.png and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png index 30ef6b9..17cc38b 100644 Binary files a/extension/icons/icon-16.png and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png index 40af5dc..c081d39 100644 Binary files a/extension/icons/icon-32.png and b/extension/icons/icon-32.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png index 020128e..c4f2df3 100644 Binary files a/extension/icons/icon-48.png and b/extension/icons/icon-48.png differ diff --git a/extension/manifest.json b/extension/manifest.json index 653eea8..b2740c1 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.14.1", + "version": "0.14.2", "description": "Control your browser from the terminal or Python SDK", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index d715a90..be77fb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.14.1" +version = "0.14.2" description = "Control your real running browser from the terminal or Python SDK" requires-python = ">=3.10" dependencies = [ @@ -8,7 +8,6 @@ dependencies = [ "cryptography>=48", "rich>=13", "msgpack>=1", - "httpx>=0.28", ] [project.optional-dependencies] diff --git a/servicelink b/servicelink deleted file mode 160000 index 7b9a51e..0000000 --- a/servicelink +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7b9a51ee525862a6e5eb99732a20aa1927d3ae62 diff --git a/uv.lock b/uv.lock index 92b4ee4..0c90d1c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,28 +2,13 @@ version = 1 revision = 3 requires-python = ">=3.10" -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - [[package]] name = "browser-cli" -version = "0.14.1" +version = "0.14.2" source = { editable = "." } dependencies = [ { name = "click" }, { name = "cryptography" }, - { name = "httpx" }, { name = "msgpack" }, { name = "rich" }, ] @@ -44,7 +29,6 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8" }, { name = "cryptography", specifier = ">=48" }, - { name = "httpx", specifier = ">=0.28" }, { name = "msgpack", specifier = ">=1" }, { name = "rich", specifier = ">=13" }, { name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" }, @@ -58,15 +42,6 @@ dev = [ { name = "zstandard", specifier = ">=0.22" }, ] -[[package]] -name = "certifi" -version = "2026.5.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, -] - [[package]] name = "cffi" version = "2.0.0" @@ -357,52 +332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0"