Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8f6d89f
gitignore the local backlog file
wlu03 Jul 28, 2026
efa6f9c
dashboard: only call a bug verified when it was signed off, not merel…
wlu03 Jul 28, 2026
bff8a32
driver: claude-opus-5 brain with structured outputs and a cached prom…
wlu03 Jul 28, 2026
cead250
docs: drop the unimplemented local detector backend and correct READM…
wlu03 Jul 28, 2026
4855429
adapters: local web always uses the local Chrome adapter, and fail fa…
wlu03 Jul 28, 2026
875a492
tests: update ledger and adapter-routing expectations to the new cont…
wlu03 Jul 28, 2026
bc3c8a6
audit: deterministic DOM audit on the local CDP path, with vendored a…
wlu03 Jul 28, 2026
09e7478
adapters: capture the CDP Network domain as a bounded, failure-prefer…
wlu03 Jul 28, 2026
5b7172d
adapters: CDP navigation, history and viewport emulation primitives
wlu03 Jul 28, 2026
d542559
adapters: capture and replay session state (cookies + web storage) ov…
wlu03 Jul 28, 2026
d72d77a
verify: wire the oracle from report_issue and check_assertions into e…
wlu03 Jul 28, 2026
754a48f
server: expose the fix loop (update_finding_status + verify_fix) in t…
wlu03 Jul 28, 2026
2b2f05a
actions: drag gets a destination and scroll gets direction+amount
wlu03 Jul 28, 2026
344d750
actions: navigate/back/forward/reload/hover/right_click, with an expl…
wlu03 Jul 28, 2026
5e88b2e
network: surface bounded traffic on observe/act and file failed reque…
wlu03 Jul 28, 2026
811516c
assertions: real network checks, plus set_viewport and captured sessi…
wlu03 Jul 28, 2026
51df28e
plan: persist test plans per repo and make a saved suite re-runnable
wlu03 Jul 28, 2026
0722498
verify: replay a finding on its own route, or report it could not be …
wlu03 Jul 28, 2026
9cec5b6
verify: replay every recorded action type, with the drag destination …
wlu03 Jul 28, 2026
f973ce7
docs: escape pipes so a tool's verdict set does not break the referen…
wlu03 Jul 28, 2026
b3356f3
docs: correct the README tool counts to the current profile split
wlu03 Jul 28, 2026
5ef496b
audit: forward the axe failure reason through the MCP boundary
wlu03 Jul 28, 2026
1c63f1f
plan: refuse an unsupported scroll direction instead of scrolling down
wlu03 Jul 28, 2026
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# boxes the host agent grounds on. This is the one key you need to get started.
REPLICATE_API_TOKEN=

# Detector backend: replicate (default, uses the token above) | http | local
# Detector backend: replicate (default, uses the token above) | http
# These two are the only supported values; anything else fails fast at detect time.
INSPECTOR_DETECTOR=replicate
# If INSPECTOR_DETECTOR=http, point at a self-hosted OmniParser FastAPI server:
INSPECTOR_OMNIPARSER_URL=
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ infra/android-redroid/data/

# Fetched benchmark demo apps (see benchmarks/manifests/) - never re-vendor
.demo-apps/

# Local backlog / planning list — kept out of the repo
docs/TODO.md
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
![MCP](https://img.shields.io/badge/protocol-MCP-6f42c1.svg)
![Surfaces](https://img.shields.io/badge/surfaces-web%20%7C%20electron%20%7C%20android%20%7C%20ios-0aa.svg)
![Status](https://img.shields.io/badge/status-building-orange.svg)
![Tests](https://img.shields.io/badge/tests-100%2B-green.svg)
![Tests](https://img.shields.io/badge/tests-passing-green.svg)
![License](https://img.shields.io/badge/license-MIT-green.svg)

**An MCP server that lets your coding agent see, click, and test the app it just built, then hand back reproducible findings so it can fix bugs on its own. Works on web, Electron, Android, and iOS.**
Expand Down Expand Up @@ -37,7 +37,7 @@ Inspector plugs into Claude Code, Cursor, or any MCP coding agent. It spins up a

## Status

Web is live and proven end to end. Pure-Python core with 13+ MCP tools, `audit_dom`, adversarial planning, and findings plus replay (100+ tests). Electron is one refactor out; Android and iOS adapters are in progress. All four surfaces are in scope as a personal dev tool (no hosting or payments yet).
Web is live and proven end to end. Pure-Python core with 32 MCP tools — 16 exposed by default under the `core` profile, the remaining 16 advanced and admin tools unlocked with `INSPECTOR_PROFILE=full` — plus `audit_dom`, adversarial planning, and findings plus replay, all covered by the unit suite (`pytest -q`). Electron is one refactor out; Android and iOS adapters are in progress. All four surfaces are in scope as a personal dev tool (no hosting or payments yet).

## Quickstart

Expand All @@ -55,7 +55,7 @@ pytest -q # unit tests
inspector/ # core plus adapters (web, electron, android, ios) plus perception
infra/ # how each VM is provisioned
examples/ # one buggy sample app per surface
docs/ # design docs 01 through 13
docs/ # design docs 01 through 16
scripts/ # run helpers, doctor, probes
```

Expand Down
16 changes: 11 additions & 5 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,28 @@
_Generated from the server by `scripts/gen_docs.py`. Do not edit by hand;
run `python scripts/gen_docs.py` to regenerate._

The default `core` profile exposes 11 tools; `INSPECTOR_PROFILE=full` exposes all 26.
The default `core` profile exposes 16 tools; `INSPECTOR_PROFILE=full` exposes all 32.

## Core tools (default profile)

| Tool | Kind | Description |
|---|---|---|
| `act` | write | Perform one action and return the post-action Set-of-Mark image + `changed` + logs. |
| `audit_dom` | write | Run a DETERMINISTIC DOM audit (web/Electron) and file any issues as findings. |
| `capture_state` | write | Save the app's CURRENT session (cookies + web storage) for later runs to replay. |
| `check` | write | Check for NEW runtime errors and return a screenshot. Three-valued; never a false pass. |
| `check_assertions` | write | Evaluate typed assertions against the live app: each pass | fail | inconclusive. |
| `check_assertions` | write | Evaluate typed assertions against the live app: each pass \| fail \| inconclusive. |
| `get_findings` | read-only | Return the findings collected this session (from the deterministic log tap). |
| `launch_app` | destructive | Boot the app in a sandbox and (by default) wait until it's interactive. |
| `launch_status` | read-only | Poll a background launch (from `launch_app(wait=false)`). |
| `observe` | read-only | Screenshot the running app and return a Set-of-Mark image + element list + recent logs. |
| `report_issue` | write | File a finding the HOST agent judged from the screenshot (host-as-brain). |
| `seed_state` | write | Install a captured session so the run starts logged in, before you test anything. |
| `set_viewport` | write | Resize the app's viewport — this is what makes the responsive checks executable. |
| `stop` | destructive | Tear down the sandbox (released first), then write the replay (html + video). |
| `test_app` | destructive | ONE CALL: launch the app in a VM, autonomously explore it, and return the bugs found. |
| `update_finding_status` | write | Record fix-loop progress on a finding: open \| fixed \| verified \| dismissed. |
| `verify_fix` | destructive | Re-verify ONE finding is fixed by replaying its exact repro on the current build. |

## Advanced tools (`INSPECTOR_PROFILE=full`)

Expand All @@ -30,13 +35,14 @@ The default `core` profile exposes 11 tools; `INSPECTOR_PROFILE=full` exposes al
| `devin_status` | external | Poll a Devin fix session; if it opened a PR, record `pr_url` on the issue. |
| `fix_finding` | read-only | Get the actionable fix context for one finding — the live agent fix loop. |
| `fix_with_devin` | destructive | Hand any surfaced issue to Devin AI — it opens a PR with the fix. |
| `get_plan` | read-only | One saved plan in full: every scenario, its current status, and its run history. |
| `get_run` | read-only | Full detail for one past session: meta, plan, findings (with fix prompts), counts. |
| `list_plans` | read-only | The saved test plans for one app — the suites you can re-run against a new build. |
| `list_runs` | read-only | List past Inspector sessions (newest first) with verdict + findings + replay path. |
| `open_dashboard` | write | Build + serve the dashboard on localhost and return a clickable link. |
| `set_plan` | write | Record the overall test plan: the scenarios (app parts/flows/edge cases) to cover. |
| `run_plan` | destructive | RE-RUN a saved plan against the current build — "re-run my checkout suite" in one call. |
| `set_plan` | write | Record the test plan — the scenarios to cover — and SAVE it as a re-runnable suite. |
| `test_app_parallel` | destructive | PLAN → DISPATCH → MERGE: a planner maps the app into parts, then a headless agent |
| `test_feature` | destructive | Cartographer — region-decomposed DETERMINISTIC bug sweep: "I built X, hand me fixes". |
| `test_report` | write | Return the full test run: per-scenario status + notes + findings, plus totals. |
| `update_finding_status` | write | Record fix-loop progress on a finding: open | fixed | verified | dismissed. |
| `update_scenario` | write | Record a scenario's outcome once you've tested it. |
| `verify_fix` | destructive | Re-verify ONE finding is fixed by replaying its exact repro on the current build. |
21 changes: 16 additions & 5 deletions inspector/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ def get_adapter(surface: Surface, config: Config, repo_path: str | None = None)
_guard_local_exec(config)
from .local_electron import LocalElectronAdapter
return LocalElectronAdapter(config)
# Local web via headless Chrome — opt-in when a URL or prebuilt dist is configured
# (for real apps like Angular/Capacitor that don't fit the E2B build-and-serve path).
import os as _os
if (config.execution == "local" and surface == Surface.WEB
and (_os.environ.get("INSPECTOR_WEB_URL") or _os.environ.get("INSPECTOR_WEB_DIST"))):
# Local web via headless Chrome. Unconditional: LocalWebAdapter resolves its URL
# from INSPECTOR_WEB_URL, then INSPECTOR_WEB_DIST, then the project's own dev
# command — so it works with no env vars set. Gating it on those two used to drop
# a "local" caller through to the E2B WebAdapter, which then billed a sandbox and
# died on a missing key; local must mean local.
if config.execution == "local" and surface == Surface.WEB:
_guard_local_exec(config)
from .local_web import LocalWebAdapter
return LocalWebAdapter(config)
Expand All @@ -60,6 +61,16 @@ def get_adapter(surface: Surface, config: Config, repo_path: str | None = None)
_guard_local_exec(config)
from .macos_native import MacNativeAdapter
return MacNativeAdapter(config)
# Everything left for web/Electron is the E2B Linux plane. Fail here, with the name
# of the missing key and the local alternative, rather than several layers deeper
# inside the e2b client where the error is an opaque auth failure. Android/iOS use
# their own planes (emulator / tart), so they are not gated on this.
if surface in (Surface.WEB, Surface.ELECTRON) and not config.e2b_api_key:
raise RuntimeError(
f"sandboxed execution of the {surface.value} surface needs E2B_API_KEY, which is "
"not set. Set it, or run the app on this machine instead with "
"INSPECTOR_EXECUTION=local."
)
return REGISTRY[surface](config)


Expand Down
117 changes: 117 additions & 0 deletions inspector/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@
from ..models import ActionType, Element, Surface


class UnsupportedAction(RuntimeError):
"""Raised when a surface is asked for something it cannot do.

The alternative — returning quietly — is the failure mode this whole layer exists to
avoid: an agent that asked for a navigation, got no exception, and now reads the
unchanged home screen as evidence about the route it never visited. Every report
carries the surface and the reason, so the caller can pick a different route to the
same check instead of guessing.
"""


@dataclass
class InputAction:
"""A normalized input event, dispatched by an adapter to its surface backend."""
Expand All @@ -19,6 +30,10 @@ class InputAction:
key: str | None = None
direction: str = "down"
amount: int = 3
# NAVIGATE carries its target here. It never reaches `input()` — the session sends
# it to `navigate()` — but it travels as the same resolved event so one action has
# one representation through the trace and the logs.
url: str = ""


class SurfaceAdapter(ABC):
Expand All @@ -31,6 +46,21 @@ class SurfaceAdapter(ABC):

surface: Surface

# The ActionTypes this adapter's `input()` really dispatches. Every adapter routes
# input through an if/elif chain over the type, and a chain has no else: an action
# it doesn't know falls off the end, returns None, and reads to the caller exactly
# like an action that worked. Declaring the set lets the session refuse the call
# with a reason instead. The default is the input vocabulary every surface has
# always implemented; an adapter that handles more says so by overriding it.
input_actions: frozenset[ActionType] = frozenset({
ActionType.CLICK, ActionType.DOUBLE_CLICK, ActionType.TYPE,
ActionType.SCROLL, ActionType.DRAG, ActionType.KEY, ActionType.WAIT,
})

def supports_input(self, action_type: ActionType) -> bool:
"""Whether `input()` on this surface actually dispatches `action_type`."""
return action_type in self.input_actions

@abstractmethod
def launch(self, repo_path: str, dev_command: str | None = None) -> None:
"""Boot the app in its runtime (does not block on readiness)."""
Expand Down Expand Up @@ -71,6 +101,93 @@ def audit_dom(self) -> dict:
"""
return {}

def network(self) -> list[dict]:
"""HTTP requests the app made since the previous call — the API-facing channel.

Most bugs in an app someone just built are backend bugs: a 500, a 404 on a
mistyped route, a CORS rejection, a fetch that hangs. None of those print a
console line or change a pixel, so `logs()` and the screenshot both report a
clean run. Records are {request_id, method, url, resource_type, status,
mime_type, failed, error, duration_ms}.

Kept separate from `logs()` on purpose — a surface must not fold network events
into its console tap, or the same failure is counted on both channels. Surfaces
that can't observe traffic inherit this empty default, exactly like `audit_dom`
and `text_elements`.
"""
return []

def navigate(self, url: str) -> bool:
"""Send the app to `url`; True only if the surface actually went there.

Whole classes of bug live behind a route change — a dead link, a blank
/does-not-exist instead of a 404 view, state that survives a route it should
not. None of that is reachable while the only way in is the screen the app
happened to boot on.

The default is False, NOT a silent no-op, and that distinction is the contract:
a surface that cannot navigate (a phone screen with no address bar) must say so,
because a no-op that returns success reads to the caller as "the bogus route
rendered fine" about a page that never left home.
"""
return False

def go_back(self) -> bool:
"""Step back one entry in session history; False if the surface can't, or
if there is no entry to go back to (running off the end of the history is a
legitimate answer, not an error)."""
return False

def go_forward(self) -> bool:
"""Step forward one entry in session history; False if unsupported or already
at the newest entry. Together with `go_back` this is what makes "back/forward
after navigating keeps state coherent" an executable check."""
return False

def reload(self) -> bool:
"""Reload the current view; False if the surface can't. Reload is how state
that only LOOKS persisted gets caught — the optimistic update that was never
written, the form that silently lost its draft."""
return False

def set_viewport(self, width: int, height: int, mobile: bool = False) -> bool:
"""Resize the app's viewport to width x height CSS px; False if unsupported.

The responsive checks (a ~375px phone width: no horizontal overflow, nothing
clipped or unclickable) cannot run on a screen size fixed at launch. A surface
that overrides this MUST also move whatever coordinate space it maps element
boxes through, or every click after the resize lands at the old scale.
"""
return False

def seed_state(self, state: dict) -> bool:
"""Install a previously captured session before the app is tested.

Every session otherwise starts logged out, and on any app with auth that means
clicking through the login UI on every single run — slow, brittle, and it burns a
30-iteration budget before reaching the feature that was actually built. Seeding
turns "log in, then test" into "test", and makes a captured session replayable.

`state` is the plain dict `capture_state` returns — {origin, cookies,
local_storage, session_storage} — so it round-trips through a file unchanged.

The default is False, NOT a silent no-op, for the same reason as `navigate`: a
surface that cannot seed has to SAY so, because a caller told "seeded" will read
every logged-out screen that follows as a bug in the app rather than as a session
that was never installed.
"""
return False

def capture_state(self) -> dict:
"""Snapshot the current session as a JSON-serialisable dict; `{}` if unsupported.

The other half of `seed_state`: log in once by hand, capture, keep the dict, and
every later run starts authenticated. `{}` is the honest empty answer for a
surface with no session to capture — it is also what a caller should refuse to
write to a state file, since seeding it back would install nothing.
"""
return {}

def detect_elements(self, screenshot: bytes) -> list[Element] | None:
"""Optional native element source (the accessibility tree).

Expand Down
57 changes: 13 additions & 44 deletions inspector/adapters/cdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import json

from .cdp_client import DOM_AUDIT_EXPR

# Fallback Node path (the web adapter installs Node here; Electron may have system node).
_NODE_FALLBACK = "/home/user/node/bin/node"

Expand Down Expand Up @@ -71,52 +73,18 @@ def dom_labels(sandbox, port: int) -> list[str]:
return [str(x).strip() for x in data if str(x).strip()]


# One-shot DETERMINISTIC audit over CDP: inject axe-core (from CDN), then read three
# structured signals straight off the live DOM — WCAG violations, images that failed
# to load (naturalWidth=0), and form inputs with no accessible label. These are facts,
# not vision judgments — the strongest evidence tier (parity with ui-test `browse eval`).
# One-shot DETERMINISTIC audit over CDP: run the SHARED in-page audit expression (the
# one local Electron/web evaluate directly — see cdp_client.DOM_AUDIT_EXPR) and read
# three structured signals straight off the live DOM — WCAG violations, images that
# failed to load (naturalWidth=0), and form inputs with no accessible label. These are
# facts, not vision judgments — the strongest evidence tier (parity with ui-test
# `browse eval`). The expression is interpolated, never re-written here: two copies of
# the audit would drift and the two execution planes would then report different facts.
# `awaitPromise` lets the in-page async IIFE finish (axe loads + runs) before we read.
DOM_AUDIT_JS = r"""
DOM_AUDIT_JS = (
r"""
const PORT = process.argv[2] || '9222';
const EXPR = `(async () => {
const out = { axe_violations: [], broken_images: [], unlabeled_inputs: [] };
try {
out.broken_images = [...document.images]
.filter(i => i.complete && i.naturalWidth === 0)
.map(i => i.currentSrc || i.src || '(no src)').slice(0, 50);
} catch (e) {}
try {
const forId = new Set();
document.querySelectorAll('label[for]').forEach(l => forId.add(l.getAttribute('for')));
out.unlabeled_inputs = [...document.querySelectorAll('input,select,textarea')]
.filter(el => {
if (el.type === 'hidden') return false;
const aria = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title');
const ph = el.getAttribute('placeholder');
const wrapped = el.closest('label');
const labelled = el.id && forId.has(el.id);
return !(aria || ph || wrapped || labelled);
})
.map(el => el.name || el.id || el.type || 'input').slice(0, 50);
} catch (e) {}
try {
if (!window.axe) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js';
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
setTimeout(rej, 6000);
});
}
if (window.axe) {
const r = await window.axe.run(document, { resultTypes: ['violations'] });
out.axe_violations = r.violations.map(v => ({
id: v.id, impact: v.impact, help: v.help, nodes: (v.nodes || []).length }));
}
} catch (e) { out.axe_error = String(e); }
return JSON.stringify(out);
})()`;
const EXPR = `""" + DOM_AUDIT_EXPR + r"""`;
async function main() {
let page = null;
for (let i = 0; i < 20 && !page; i++) {
Expand All @@ -142,6 +110,7 @@ def dom_labels(sandbox, port: int) -> list[str]:
}
main();
"""
)


def audit_dom(sandbox, port: int) -> dict:
Expand Down
Loading
Loading