diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml new file mode 100644 index 0000000..f5cf845 --- /dev/null +++ b/.github/workflows/install-e2e.yml @@ -0,0 +1,31 @@ +name: Install E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + install-e2e: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.12", "3.13"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Build wheel + run: | + pip install build + python -m build --wheel brow/ -o dist + - name: Install from wheel + run: pip install dist/*.whl + - name: Run e2e + run: bash scripts/e2e.sh + env: + BROW_SETUP_ARGS: ${{ runner.os == 'Linux' && '--with-deps' || '' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6776a46..d32906e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- `brow setup` command to install Chromium (no more manual `patchright install`) + +### Changed +- License reverted to MIT (was Elastic License 2.0) +- `session new` returns a clear "Chromium is not installed" message instead of a raw 500 when the browser binary is missing + ## [1.1.0] - 2026-04-08 ### Added diff --git a/Dockerfile b/Dockerfile index 0b869ee..defac70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /app COPY brow/ /app/brow/ RUN pip install --no-cache-dir /app/brow/ -RUN playwright install --with-deps chromium +RUN brow setup --with-deps COPY benchmarks/ /app/benchmarks/ RUN pip install --no-cache-dir -r /app/benchmarks/requirements.txt diff --git a/Dockerfile.e2e b/Dockerfile.e2e new file mode 100644 index 0000000..3245fa5 --- /dev/null +++ b/Dockerfile.e2e @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY brow/ /app/brow/ +COPY scripts/e2e.sh /app/scripts/e2e.sh + +RUN pip install --no-cache-dir build && python -m build --wheel brow/ -o /app/dist +RUN pip install --no-cache-dir /app/dist/*.whl + +ENV BROW_SETUP_ARGS=--with-deps +ENTRYPOINT ["bash", "scripts/e2e.sh"] diff --git a/LICENSE b/LICENSE index fdb9d34..31fc650 100644 --- a/LICENSE +++ b/LICENSE @@ -1,91 +1,21 @@ -Elastic License 2.0 (ELv2) - -## Acceptance - -By using the software, you agree to all of the terms and conditions below. - -## Copyright License - -The licensor grants you a non-exclusive, royalty-free, worldwide, -non-sublicensable, non-transferable license to use, copy, distribute, make -available, and prepare derivative works of the software, in each case subject -to the limitations and conditions below. - -## Limitations - -You may not provide the software to third parties as a hosted or managed -service, where the service provides users with access to any substantial set -of the features or functionality of the software. - -You may not move, change, disable, or circumvent the license key functionality -in the software, and you may not remove or obscure any functionality in the -software that is protected by the license key. - -You may not alter, remove, or obscure any licensing, copyright, or other -notices of the licensor in the software. Any use of the licensor's trademarks -is subject to applicable law. - -## Patents - -The licensor grants you a license, under any patent claims the licensor can -license, or becomes able to license, to make, have made, use, sell, offer for -sale, import and have imported the software, in each case subject to the -limitations and conditions in this license. This license does not cover any -patent claims that you cause to be infringed by modifications or additions to -the software. If you or your company make any written claim that the software -infringes or contributes to infringement of any patent, your patent license -for the software granted under these terms ends immediately. If your company -makes such a claim, your patent license ends immediately for work on behalf -of your company. - -## Notices - -You must ensure that anyone who gets a copy of any part of the software from -you also gets a copy of these terms. - -If you modify the software, you must include in any modified copies of the -software prominent notices stating that you have modified the software. - -## No Other Rights - -These terms do not imply any licenses other than those expressly granted in -these terms. - -## Termination - -If you use the software in violation of these terms, such use is not licensed, -and your licenses will automatically terminate. If the licensor provides you -with a notice of your violation, and you cease all violation of this license -no later than 30 days after you receive that notice, your licenses will be -reinstated retroactively. However, if you violate these terms after such -reinstatement, any additional violation of these terms will cause your licenses -to terminate automatically and permanently. - -## No Liability - -*As far as the law allows, the software comes as is, without any warranty or -condition, and the licensor will not be liable to you for any damages arising -out of these terms or the use or nature of the software, under any kind of -legal claim.* - -## Definitions - -The **licensor** is the entity offering these terms, and the **software** is -the software the licensor makes available under these terms, including any -portion of it. - -**You** refers to the individual or entity agreeing to these terms. - -**Your company** is any legal entity, sole proprietorship, or other kind of -organization that you work for, plus all organizations that have control over, -are under the control of, or are under common control with that organization. -**Control** means ownership of substantially all the assets of an entity, or -the power to direct its management and policies by vote, contract, or -otherwise. Control can be direct or indirect. - -**Your licenses** are all the licenses granted to you for the software under -these terms. - -**Use** means anything you do with the software requiring one of your licenses. - -**Trademark** means trademarks, service marks, and similar rights. +MIT License + +Copyright (c) 2026 Daniel Herman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2cf07d7..28f0753 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -# brow - agentic browsing +# brow — browser automation for AI agents [![CI](https://github.com/detrin/brow/actions/workflows/test.yml/badge.svg)](https://github.com/detrin/brow/actions/workflows/test.yml) [![PyPI](https://img.shields.io/pypi/v/brow-cli)](https://pypi.org/project/brow-cli/) [![Python](https://img.shields.io/pypi/pyversions/brow-cli)](https://pypi.org/project/brow-cli/) -[![License: ELv2](https://img.shields.io/badge/license-Elastic%202.0-blue.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -Standalone Playwright CLI for agent browser automation. Launches a real Chromium instance with an agent-friendly API - structured commands for common actions, eval escape hatch for full power. +**The browser tool agents win with: 82% task success at ~$0.22/task — beating browser-use, playwright-mcp, and agent-browser on [22 benchmark tasks](#benchmarks).** + +A standalone Playwright CLI that gives your agent a real Chromium instance with an agent-friendly API — structured commands for common actions, plus an `eval` escape hatch for full power. ![brow demo](https://github.com/user-attachments/assets/27c6114c-451b-4e64-b66d-2268248be79b) @@ -20,7 +22,11 @@ brew install brow **pip:** ```bash pip install brow-cli -patchright install chromium +``` + +Then install Chromium once (either method above): +```bash +brow setup # ~150MB, one-time ``` **Agent skill:** diff --git a/brow/README.md b/brow/README.md index 78df320..2a3d2b2 100644 --- a/brow/README.md +++ b/brow/README.md @@ -15,7 +15,11 @@ brew install brow **pip:** ```bash pip install brow-cli -patchright install chromium +``` + +Then install Chromium once (either method above): +```bash +brow setup # ~150MB, one-time ``` **Agent skill:** diff --git a/brow/pyproject.toml b/brow/pyproject.toml index 0942a39..0d1464c 100644 --- a/brow/pyproject.toml +++ b/brow/pyproject.toml @@ -7,7 +7,7 @@ name = "brow-cli" version = "1.1.0" description = "Standalone Playwright CLI for agent browser automation" readme = {file = "README.md", content-type = "text/markdown"} -license = "Elastic-2.0" +license = "MIT" authors = [{name = "Daniel Herman", email = "daniel.herman@protonmail.com"}] requires-python = ">=3.12" dependencies = [ diff --git a/brow/src/brow/cli.py b/brow/src/brow/cli.py index dd8377a..e00aff9 100644 --- a/brow/src/brow/cli.py +++ b/brow/src/brow/cli.py @@ -65,6 +65,19 @@ def ensure_daemon(): raise typer.Exit(1) +@app.command("setup") +def setup_cmd(with_deps: bool = typer.Option(False, "--with-deps", help="Also install OS-level dependencies")): + cmd = [sys.executable, "-m", "patchright", "install"] + if with_deps: + cmd.append("--with-deps") + cmd.append("chromium") + typer.echo("Installing Chromium for brow (~150MB, one-time)...") + if subprocess.run(cmd).returncode != 0: + typer.echo("Setup failed. Try: patchright install chromium", err=True) + raise typer.Exit(1) + typer.echo("Setup complete. Try: brow session new") + + @daemon_app.command("start") def daemon_start( port: int = DAEMON_PORT, wait: bool = typer.Option(False, "--wait", help="Block until daemon is ready") diff --git a/brow/src/brow/routes/sessions.py b/brow/src/brow/routes/sessions.py index 4c95413..1e7c857 100644 --- a/brow/src/brow/routes/sessions.py +++ b/brow/src/brow/routes/sessions.py @@ -4,8 +4,12 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel +from brow.session import is_browser_missing_error + router = APIRouter(prefix="/sessions", tags=["sessions"]) +_BROWSER_MISSING_MSG = "Chromium is not installed. Run: brow setup (or: patchright install chromium)" + class CreateSession(BaseModel): profile: str = "default" @@ -24,7 +28,13 @@ async def create(req: Request, body: CreateSession): raise HTTPException(400, str(e)) session = mgr.get(sid) user_data_dir = profiles.get_profile_dir(body.profile) - await session.launch(pw, user_data_dir) + try: + await session.launch(pw, user_data_dir) + except Exception as e: + mgr.delete(sid) + if is_browser_missing_error(e): + raise HTTPException(503, _BROWSER_MISSING_MSG) + raise HTTPException(500, f"Failed to launch browser: {e}") resp = {"id": sid, "profile": body.profile} diff --git a/brow/src/brow/session.py b/brow/src/brow/session.py index 20c45c4..77b66af 100644 --- a/brow/src/brow/session.py +++ b/brow/src/brow/session.py @@ -2,6 +2,13 @@ from brow.config import MAX_SESSIONS +_MISSING_BROWSER_HINTS = ("Executable doesn't exist", "playwright install", "patchright install", "BrowserType.launch") + + +def is_browser_missing_error(e): + msg = str(e) + return any(h in msg for h in _MISSING_BROWSER_HINTS) + @dataclass class Session: diff --git a/brow/tests/test_cli.py b/brow/tests/test_cli.py index 11d1dfa..d6351dd 100644 --- a/brow/tests/test_cli.py +++ b/brow/tests/test_cli.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -77,6 +77,20 @@ def test_fill_no_args(): assert result.exit_code != 0 +def test_setup_runs_patchright(): + with patch("brow.cli.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: + result = runner.invoke(app, ["setup"]) + assert result.exit_code == 0 + assert "chromium" in mock_run.call_args[0][0] + assert "Setup complete" in result.output + + +def test_setup_failure(): + with patch("brow.cli.subprocess.run", return_value=MagicMock(returncode=1)): + result = runner.invoke(app, ["setup"]) + assert result.exit_code == 1 + + def test_api_error_surfaced(): """Test that BrowAPIError from the client is caught and printed cleanly.""" diff --git a/brow/tests/test_routes_sessions.py b/brow/tests/test_routes_sessions.py index f34f7ae..0516297 100644 --- a/brow/tests/test_routes_sessions.py +++ b/brow/tests/test_routes_sessions.py @@ -49,6 +49,18 @@ async def test_status(client): assert "sessions" in r.json() +@pytest.mark.asyncio +async def test_create_session_browser_missing(client, monkeypatch): + async def _fail(self, *a, **k): + raise Exception("Executable doesn't exist at /chromium. Run playwright install") + + monkeypatch.setattr("brow.session.Session.launch", _fail) + r = await client.post("/sessions", json={"profile": "missing", "headless": True}) + assert r.status_code == 503 + assert "brow setup" in r.json()["detail"] + assert (await client.get("/sessions")).json() == [] + + @pytest.mark.asyncio async def test_create_session_with_url(client): r = await client.post( diff --git a/brow/tests/test_session.py b/brow/tests/test_session.py index 51b9f17..8f7d283 100644 --- a/brow/tests/test_session.py +++ b/brow/tests/test_session.py @@ -1,6 +1,6 @@ import pytest -from brow.session import SessionManager +from brow.session import SessionManager, is_browser_missing_error @pytest.fixture @@ -57,3 +57,17 @@ def test_duplicate_profile(manager): manager.create("gmail", headless=True) with pytest.raises(RuntimeError, match="already in use"): manager.create("gmail", headless=True) + + +@pytest.mark.parametrize( + "msg,expected", + [ + ("Executable doesn't exist at /path/chromium", True), + ("looks like Playwright was just installed. Run playwright install", True), + ("please run patchright install chromium", True), + ("Timeout 30000ms exceeded", False), + ("Session 1 not found", False), + ], +) +def test_is_browser_missing_error(msg, expected): + assert is_browser_missing_error(Exception(msg)) is expected diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..9817bce --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +export BROW_HOME="${BROW_HOME:-$(mktemp -d)/.brow}" +export BROW_PORT="${BROW_PORT:-29871}" +PAGE='data:text/html,brow e2e

hello brow

' + +cleanup() { brow session delete 1 2>/dev/null || true; brow daemon stop 2>/dev/null || true; } +trap cleanup EXIT +cleanup + +assert() { echo "$1" | grep -qF "$2" || { echo "FAIL: expected '$2' in:"; echo "$1"; exit 1; }; } + +echo "==> brow setup" +brow setup ${BROW_SETUP_ARGS:-} + +echo "==> session new" +assert "$(brow session new --url "$PAGE")" "hello brow" + +echo "==> url" +assert "$(brow url -s 1)" "brow e2e" + +echo "==> snapshot" +assert "$(brow snapshot -s 1)" "Go" + +echo "==> eval" +assert "$(brow eval -s 1 'result = await page.title()')" "brow e2e" + +echo "==> click" +brow click -s 1 "#go" + +echo "PASS: e2e ok ($(brow --version 2>/dev/null || echo brow))" diff --git a/skills/brow/SKILL.md b/skills/brow/SKILL.md index 6c2fafc..f83edc8 100644 --- a/skills/brow/SKILL.md +++ b/skills/brow/SKILL.md @@ -11,7 +11,7 @@ Control a real Chromium browser from Claude Code. Navigate pages, click buttons, ```bash pip install brow-cli -patchright install chromium +brow setup ``` ## Usage diff --git a/website/src/content/docs/getting-started.md b/website/src/content/docs/getting-started.md index 39ef2f0..9e7a412 100644 --- a/website/src/content/docs/getting-started.md +++ b/website/src/content/docs/getting-started.md @@ -9,7 +9,7 @@ Install the Python package and the Chromium browser: ```bash pip install brow-cli -patchright install chromium +brow setup ``` Verify the installation: