Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ __pycache__/
.venv/
*.png
*.pdf
.ruff_cache/
.worktrees/
.pytest_cache/
.DS_Store
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 3 additions & 2 deletions browser_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
16 changes: 15 additions & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
9 changes: 9 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading