diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c649924 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +# Lint + host-free tests. Runs on the org's Namespace Linux profile (overridable via the +# NSC_RUNNER repo var; falls back to a hosted runner on a fork). The suite mocks the +# agent-browser CLI, so no browser/binary is needed. +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: ${{ vars.NSC_RUNNER || 'namespace-profile-protolabs-linux' }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dev deps + run: pip install -r requirements-dev.txt ruff + + - name: Lint + run: ruff check . + + - name: Test + run: pytest -q diff --git a/.gitignore b/.gitignore index 1aba3fc..7c74f04 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ .venv/ *.png *.pdf +.ruff_cache/ +.worktrees/ +.pytest_cache/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..89a3344 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## v0.3.0 +- **Host-free test suite** (`tests/`) — the tool subprocess wrappers (arg-building + + graceful error degradation), the panel routers (page / four-rules / gating / dashboard + proxy 502), `register()` wiring, the dashboard lifecycle, and manifest/version coherence. + subprocess is mocked, so no `agent-browser` binary and no real browser are needed. +- **CI** (`.github/workflows/ci.yml`) — `ruff check` + `pytest` on every PR. +- **Settings** — the operator config (panel mode, headed, allowed domains, profile, …) is + now editable in **Settings ▸ Plugins** (`settings:` block; `panel_mode` is a select). +- Fixed two dead locals in `build_panel_router` (lint was red — it had gone unnoticed + without CI) and re-synced `pyproject.toml` (was `0.1.0`) with the manifest version. + +## v0.2.0 +- Gated the minimal-mode `shot`/`nav` data routes under `/api/plugins/agent_browser` + (plugin-view rule 2) and adopted the DS plugin-kit for the panel chrome (#8). + +## v0.1.1 +- Serve the full-mode dashboard same-origin via a reverse proxy so it rides the fleet + proxy on a member box instead of a hardcoded `localhost:PORT` (#6/#7); link the DS kit + same-origin (`/_ds/`) instead of the CDN (#5). + +## v0.1.0 +- Initial release: browser tools over the `agent-browser` CLI, a discovery skill, browser + workflows, the Browser panel (`full` | `minimal`), and the dashboard lifecycle surface. diff --git a/README.md b/README.md index 6036cb0..0a4bfa3 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,25 @@ agent_browser: | File | What | |---|---| | `tools.py` | the browser tools — subprocess wrappers over the `agent-browser` CLI | -| `browser_panel.py` | the console view that embeds the agent-browser dashboard | +| `browser_panel.py` | the console view that embeds the agent-browser dashboard (same-origin reverse proxy) | +| `lifecycle.py` | the dashboard daemon surface — start on boot, stop on shutdown (ADR 0018) | | `skills/` | the discovery skill (defers to `agent-browser skills get core`) | | `workflows/` | declarative browser recipes | -| `__init__.py` | `register()` — wires tools + panel; skills/workflows auto-discovered | +| `tests/` | the host-free pytest suite (subprocess mocked — no binary needed) | +| `__init__.py` | `register()` — wires tools + panel + lifecycle; skills/workflows auto-discovered | + +The operator knobs (panel mode, headed, allowed domains, profile, device, …) are editable in +**Settings ▸ Plugins ▸ Agent Browser**, or under `agent_browser:` in `langgraph-config.yaml`. + +## Development + +```bash +pip install -r requirements-dev.txt +pytest -q # host-free — subprocess is mocked, no agent-browser binary needed +ruff check . +``` + +CI runs the same on every PR. Ships **disabled**; nothing runs until you enable it and the `agent-browser` binary is installed. diff --git a/browser_panel.py b/browser_panel.py index be3c030..d799010 100644 --- a/browser_panel.py +++ b/browser_panel.py @@ -54,8 +54,9 @@ def build_panel_router(cfg: dict | None): cfg = cfg or {} port = int(cfg.get("dashboard_port", 4848)) mode = str(cfg.get("panel_mode", "full")).strip().lower() - binary = str(cfg.get("binary") or "agent-browser") - timeout = float(cfg.get("timeout_s", 60)) + # NB: the page + dash-proxy routes don't shell out (binary/timeout) — they only + # reverse-proxy the dashboard daemon on `port`. The shot/nav DATA routes that DO + # run the CLI live in build_panel_data_router (gated under /api). router = APIRouter() diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 044d369..1955ae7 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: agent_browser name: Agent Browser -version: 0.2.0 +version: 0.3.0 description: >- Browser automation for protoAgent, backed by **agent-browser** (vercel-labs) — a fast native-Rust CLI/daemon that drives Chrome over CDP with accessibility-tree @@ -36,6 +36,20 @@ config: manage_dashboard: true # own the dashboard lifecycle (ADR 0018): start it on boot, stop it on # shutdown. Set false to leave a shared/persistent dashboard untouched. +# Editable in Settings ▸ Plugins (ADR 0019) — the operator knobs above as UI fields. +settings: + - { key: panel_mode, label: "Browser panel mode", type: select, options: [full, minimal], description: "full = iframe agent-browser's live dashboard (viewport + activity/console/network feeds); minimal = viewport-only (a polled screenshot + a nav toolbar)." } + - { key: headed, label: "Headed browser", type: bool, description: "Show a real browser window instead of running headless." } + - { key: allowed_domains, label: "Allowed domains", type: string, description: "Comma-separated navigable-domain allowlist (e.g. example.com,*.foo.com). Blank = unrestricted." } + - { key: confirm_actions, label: "Confirm actions", type: string, description: "Comma-separated action categories that require confirmation before the agent runs them." } + - { key: profile, label: "Browser profile dir", type: string, description: "Path to a browser profile directory — isolation + persisted auth/cookies across sessions." } + - { key: device, label: "Device emulation", type: string, description: "Emulate a device, e.g. \"iPhone 16 Pro\". Blank = desktop." } + - { key: max_output, label: "Max page-text output (chars)", type: number, description: "Cap characters of page text returned to the model (LLM-safety). 0 = the CLI default." } + - { key: dashboard_port, label: "Dashboard port", type: number, description: "Port for agent-browser's dashboard daemon; the full-mode panel reverse-proxies it." } + - { key: timeout_s, label: "Command timeout (s)", type: number, description: "Per-command subprocess timeout for the browser tools." } + - { key: manage_dashboard, label: "Manage dashboard lifecycle", type: bool, description: "Start the dashboard on boot and stop it on shutdown. Turn off to leave a shared/persistent dashboard untouched." } + - { key: binary, label: "agent-browser binary", type: string, description: "The agent-browser CLI on PATH (override with a pinned absolute path if needed)." } + # Console view (ADR 0026) — embeds agent-browser's live dashboard (viewport + feeds). views: - { id: panel, label: "Browser", icon: Globe, path: /plugins/agent_browser/panel } diff --git a/pyproject.toml b/pyproject.toml index d6bc8b9..dd1cb6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-browser-plugin" -version = "0.1.0" +version = "0.3.0" description = "Browser-automation plugin for protoAgent, backed by vercel-labs/agent-browser (tools + skill + workflows + a live browser panel)." requires-python = ">=3.11" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a946ba1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +# Test-only deps. At runtime the host (protoAgent) provides fastapi + langchain-core +# (and httpx/websockets for the dashboard proxy); the only runtime requirement is the +# `agent-browser` CLI on PATH. These let the host-free suite run standalone in CI. +fastapi>=0.110 +langchain-core>=0.2 +httpx>=0.27 +pyyaml>=6 +pytest>=8 +pytest-asyncio>=0.23 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..09c2f05 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +"""Host-free test bootstrap. Register the plugin under a synthetic `agent_browser` package +so the modules' relative imports resolve with no protoAgent host — the host-only imports are +lazy (inside register()), so importing needs only the dev deps (fastapi + langchain-core). +The browser tools shell out to the `agent-browser` CLI, so the suite mocks subprocess.run — +no real binary, no real browser.""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +PKG = "agent_browser" + +if PKG not in sys.modules: + _spec = importlib.util.spec_from_file_location(PKG, ROOT / "__init__.py", submodule_search_locations=[str(ROOT)]) + assert _spec and _spec.loader + _mod = importlib.util.module_from_spec(_spec) + sys.modules[PKG] = _mod + _spec.loader.exec_module(_mod) + + +class FakeRegistry: + """Mirrors the registry surface register() touches.""" + + def __init__(self, config=None): + self.config = config or {} + self.tools = [] + self.routers = [] + self.surfaces = [] + self.skill_dirs = [] + self.workflow_dirs = [] + + def register_tool(self, t): + self.tools.append(t) + + def register_router(self, router, prefix=None): + self.routers.append((prefix, router)) + + def register_surface(self, start, stop=None, name=None): + self.surfaces.append((name, start, stop)) + + def register_skill_dir(self, path): + self.skill_dirs.append(path) + + def register_workflow_dir(self, path): + self.workflow_dirs.append(path) + + +@pytest.fixture +def registry(): + return FakeRegistry() + + +def fake_run(rc=0, stdout="ok", stderr="", record=None): + """A subprocess.run stand-in: records the argv and returns a canned CompletedProcess.""" + + def _run(args, **kw): + if record is not None: + record.append(list(args)) + return types.SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr) + + return _run diff --git a/tests/test_agent_browser.py b/tests/test_agent_browser.py new file mode 100644 index 0000000..5fb807e --- /dev/null +++ b/tests/test_agent_browser.py @@ -0,0 +1,228 @@ +"""Tests for the agent_browser plugin — the tool subprocess wrappers (arg-building + +graceful error degradation), the panel routers (page / four-rules / gating / proxy), +register() wiring, the dashboard lifecycle, and manifest/version coherence. Host-free: +subprocess.run is mocked, so no agent-browser binary and no real browser are needed.""" + +from __future__ import annotations + +from pathlib import Path + + +import agent_browser.browser_panel as bp +import agent_browser.lifecycle as lc +import agent_browser.tools as tools +from conftest import fake_run + +ROOT = Path(__file__).resolve().parent.parent + + +def _toolmap(cfg=None): + return {t.name: t for t in tools.get_browser_tools(cfg or {})} + + +# ── the tools: arg-building ────────────────────────────────────────────────────── + + +async def test_open_passes_url_and_curated_launch_flags(monkeypatch): + rec = [] + monkeypatch.setattr(tools.subprocess, "run", fake_run(stdout="OPENED", record=rec)) + t = _toolmap({"binary": "ab", "headed": True, "allowed_domains": "x.com", "max_output": 500}) + out = await t["browser_open"].ainvoke({"url": "https://x.com"}) + assert "OPENED" in out + assert rec[-1] == ["ab", "--headed", "--allowed-domains", "x.com", "--max-output", "500", "open", "https://x.com"] + + +async def test_open_blank_url_omits_it(monkeypatch): + rec = [] + monkeypatch.setattr(tools.subprocess, "run", fake_run(record=rec)) + await _toolmap({"binary": "ab"})["browser_open"].ainvoke({}) + assert rec[-1] == ["ab", "open"] + + +async def test_action_tools_pass_refs(monkeypatch): + rec = [] + monkeypatch.setattr(tools.subprocess, "run", fake_run(record=rec)) + t = _toolmap({"binary": "ab"}) + await t["browser_click"].ainvoke({"selector": "@e2"}) + assert rec[-1] == ["ab", "click", "@e2"] + await t["browser_fill"].ainvoke({"selector": "#q", "text": "hi there"}) + assert rec[-1] == ["ab", "fill", "#q", "hi there"] + await t["browser_snapshot"].ainvoke({}) + assert rec[-1] == ["ab", "snapshot"] + + +async def test_dashboard_tool_validates_action_and_passes_port(monkeypatch): + rec = [] + monkeypatch.setattr(tools.subprocess, "run", fake_run(record=rec)) + t = _toolmap({"binary": "ab", "dashboard_port": 9000}) + assert "action must be" in await t["browser_dashboard"].ainvoke({"action": "boom"}) + await t["browser_dashboard"].ainvoke({"action": "start"}) + assert rec[-1] == ["ab", "dashboard", "start", "--port", "9000"] + + +def test_all_17_tools_present(): + names = set(_toolmap()) + assert len(names) == 17 + assert { + "browser_open", + "browser_snapshot", + "browser_click", + "browser_fill", + "browser_screenshot", + "browser_eval", + "browser_close", + "browser_dashboard", + } <= names + + +# ── the tools: graceful error degradation (a failed action informs, never crashes) ── + + +async def test_missing_binary_returns_install_hint(monkeypatch): + def boom(args, **kw): + raise FileNotFoundError() + + monkeypatch.setattr(tools.subprocess, "run", boom) + out = await _toolmap({"binary": "nope"})["browser_snapshot"].ainvoke({}) + assert "not on PATH" in out and "npm i -g agent-browser" in out + + +async def test_timeout_returns_readable_error(monkeypatch): + def slow(args, **kw): + raise tools.subprocess.TimeoutExpired(cmd="ab", timeout=1) + + monkeypatch.setattr(tools.subprocess, "run", slow) + out = await _toolmap({"binary": "ab", "timeout_s": 1})["browser_snapshot"].ainvoke({}) + assert "timed out" in out + + +async def test_nonzero_exit_surfaces_stderr(monkeypatch): + monkeypatch.setattr(tools.subprocess, "run", fake_run(rc=2, stderr="boom")) + out = await _toolmap({"binary": "ab"})["browser_click"].ainvoke({"selector": "@e9"}) + assert out.startswith("Error:") and "boom" in out + + +# ── register() wiring ──────────────────────────────────────────────────────────── + + +def test_register_wires_tools_panel_data_and_surface(registry): + import agent_browser as pkg + + pkg.register(registry) + assert len(registry.tools) == 17 + prefixes = [p for p, _ in registry.routers] + assert None in prefixes # the panel PAGE (host default prefix /plugins/agent_browser) + assert "/api/plugins/agent_browser" in prefixes # gated data routes + assert registry.surfaces and registry.surfaces[0][0] == "agent-browser-dashboard" + + +# ── manifest / version coherence + settings ────────────────────────────────────── + + +def test_manifest_and_pyproject_versions_match(): + import tomllib + + import yaml + + m = yaml.safe_load((ROOT / "protoagent.plugin.yaml").read_text()) + pp = tomllib.loads((ROOT / "pyproject.toml").read_text()) + assert m["version"] == pp["project"]["version"] # the drift this test now guards + assert m["id"] == "agent_browser" and m["enabled"] is False + assert m["views"][0]["path"] == "/plugins/agent_browser/panel" + + +def test_settings_fields_are_valid_and_back_real_config(): + import yaml + + m = yaml.safe_load((ROOT / "protoagent.plugin.yaml").read_text()) + by_key = {f["key"]: f for f in m["settings"]} + assert by_key["panel_mode"]["type"] == "select" + assert set(by_key["panel_mode"]["options"]) == {"full", "minimal"} + assert by_key["headed"]["type"] == "bool" and by_key["dashboard_port"]["type"] == "number" + # every settings key has a declared default in config: + assert set(by_key) <= set(m["config"]) + + +# ── the dashboard lifecycle ────────────────────────────────────────────────────── + + +async def test_lifecycle_starts_on_boot_and_stops_on_shutdown(monkeypatch): + rec = [] + monkeypatch.setattr(lc.subprocess, "run", fake_run(record=rec)) + start, stop = lc.make_dashboard_surface({"binary": "ab", "dashboard_port": 9999}) + await start() + await stop() + assert ["ab", "dashboard", "start", "--port", "9999"] in rec + assert ["ab", "dashboard", "stop"] in rec + + +async def test_lifecycle_leaves_shared_dashboard_untouched(monkeypatch): + rec = [] + monkeypatch.setattr(lc.subprocess, "run", fake_run(record=rec)) + start, stop = lc.make_dashboard_surface({"manage_dashboard": False}) + await start() + await stop() + assert rec == [] # manage_dashboard:false → never touches the daemon + + +# ── the panel routers (page / four-rules / gating / proxy) ─────────────────────── + + +def _app(cfg=None): + from fastapi import FastAPI + + app = FastAPI() + app.include_router(bp.build_panel_router(cfg or {}), prefix="/plugins/agent_browser") + app.include_router(bp.build_panel_data_router(cfg or {}), prefix="/api/plugins/agent_browser") + return app + + +def test_panel_page_full_mode_is_four_rules_compliant(): + from fastapi.testclient import TestClient + + c = TestClient(_app({"panel_mode": "full"})) + r = c.get("/plugins/agent_browser/panel") + assert r.status_code == 200 + html = r.text + assert "/_ds/plugin-kit.css" in html # DS kit + assert 'location.pathname.split("/plugins/")[0]' in html # slug-aware base + assert "/plugins/agent_browser/panel/dash/" in html # same-origin proxied dashboard + # never a hardcoded http origin (issue #6) — the iframe src is BASE-derived same-origin. + assert "http://localhost" not in html and "http://127.0.0.1" not in html + + +def test_panel_page_minimal_mode_uses_gated_data_routes(): + from fastapi.testclient import TestClient + + html = TestClient(_app({"panel_mode": "minimal"})).get("/plugins/agent_browser/panel").text + assert "/api/plugins/agent_browser/shot" in html # gated screenshot + assert "/api/plugins/agent_browser/nav" in html # gated nav + assert "kit.apiFetch" in html # authed fetch via the kit + + +def test_shot_route_503s_without_a_frame(monkeypatch): + from fastapi.testclient import TestClient + + monkeypatch.setattr(bp.subprocess, "run", fake_run(rc=127, stderr="not found")) + monkeypatch.setattr(bp.os.path, "exists", lambda p: False) # no cached frame + monkeypatch.setattr(bp, "_shot_ts", 0.0) + r = TestClient(_app()).get("/api/plugins/agent_browser/shot") + assert r.status_code == 503 + + +def test_nav_route_validates(monkeypatch): + from fastapi.testclient import TestClient + + monkeypatch.setattr(bp.subprocess, "run", fake_run(record=[])) + c = TestClient(_app()) + assert c.post("/api/plugins/agent_browser/nav", json={"action": "bogus"}).json()["ok"] is False + assert c.post("/api/plugins/agent_browser/nav", json={"action": "open"}).json()["error"] == "url required" + assert c.post("/api/plugins/agent_browser/nav", json={"action": "reload"}).json()["ok"] is True + + +def test_dash_proxy_502s_when_daemon_down(): + from fastapi.testclient import TestClient + + # nothing listens on the dashboard port in CI → the reverse proxy returns 502, not a crash. + r = TestClient(_app({"dashboard_port": 4999})).get("/plugins/agent_browser/panel/dash/") + assert r.status_code == 502