Merge pull request #308 from apireno/fix/browser-domshell-2.0.0-migration

fix(browser): migrate DOMShell MCP integration to @apireno/domshell 2.0.0
This commit is contained in:
Yuhao
2026-06-08 18:22:49 +08:00
committed by GitHub
8 changed files with 2360 additions and 139 deletions
+47 -16
View File
@@ -37,30 +37,61 @@ DOMShell is an npm package that exposes Chrome's Accessibility Tree via MCP:
### Installation
**Requires `@apireno/domshell` 2.0.2 or newer.** The harness uses
`group_id="new"` to declare lane intent explicitly on the first call of
each session and reuses the captured lane id on subsequent calls (silences
the deprecation warning that 2.0.2 added for omitted `group_id`; will
become a hard error in DOMShell 3.0.0). For direct daemon-mode callers
without a harness `Session`, the same scheme runs at module level — the
first call captures a lane id into `_daemon_lane_id` and subsequent calls
reuse it, preserving browser state across daemon-mode no-session calls
(see `domshell_backend.py`'s `_daemon_lane_id` declaration for the
stale-lane failure mode).
```bash
# Verify DOMShell is available
npx @apireno/domshell --version
npx @apireno/domshell --version # should report 2.0.2 or higher
# Install Chrome extension
# https://chromewebstore.google.com/detail/domshell
```
### MCP Tools
The standard `npx @apireno/domshell` invocation pulls the latest published
version automatically; no manual pinning is required.
DOMShell exposes these MCP tools:
DOMShell 2.0.0 (May 2026) consolidated the MCP tool surface from 38
per-command tools to a single `domshell_execute` tool. The harness targets
this consolidated tool, so no opt-in `--granular` server flag is required.
| Tool | Description | CLI Command |
|------|-------------|-------------|
| `domshell_ls` | List directory contents | `fs ls` |
| `domshell_cd` | Change directory | `fs cd` |
| `domshell_cat` | Read element content | `fs cat` |
| `domshell_grep` | Search for pattern | `fs grep` |
| `domshell_click` | Click element | `act click` |
| `domshell_type` | Type text | `act type` |
| `domshell_open` | Navigate to URL | `page open` |
| `domshell_reload` | Reload page | `page reload` |
| `domshell_back` | Navigate back | `page back` |
| `domshell_forward` | Navigate forward | `page forward` |
### MCP Tool
DOMShell 2.0.2+ exposes a single MCP tool:
| Tool | Description |
|------|-------------|
| `domshell_execute` | Runs a shell-style command string. Multi-line input is supported — each line runs in order in the same shell state. |
The harness builds command strings from the public CLI commands. Harness
absolute paths (leading `/`) are anchored at the tab root via
`cd %here%` since DOMShell's lane cwd may have drifted; relative paths
are passed through unchanged.
| CLI Command (path is absolute) | Command string sent to `domshell_execute` |
|--------------------------------|--------------------------------------------|
| `fs ls /<sub>` | `cd %here%/<sub>` then bare `ls`, then `cd <restore>` (single multi-line call) |
| `fs cd /<sub>` | `cd %here%/<sub>` (single line — `cd` is the desired new state) |
| `fs cat /<sub>` | `cd %here%`, `cat <sub>`, `cd <restore>` (single multi-line call) |
| `fs grep <pat>` | `grep <pat>` (operates on lane cwd) |
| `fs grep <pat> /<sub>` | `cd %here%/<sub>`, `grep <pat>`, `cd <restore>` (single multi-line call) |
| `act click /<sub>` | `cd %here%`, `click <sub>`, `cd <restore>` (single multi-line call) |
| `act type /<sub> <text>` | `cd %here%`, `focus <sub>`, `cd <restore>` — then, on success, `type <text>` (two calls, shared lane via `group_id`) |
| `page open <url>` | `open <url>` |
| `page reload` | `refresh` |
| `page back` | `back` |
| `page forward` | `forward` |
`<restore>` resolves to `cd %here%/<harness-working-dir>` (or `cd %here%`
when the harness is at the tab root) so the lane's cwd ends up where
the harness expects.
## Key Design Decisions
@@ -226,6 +226,13 @@ def fs_ls(path):
if _json_output:
output(result)
else:
# Surface DOMShell errors (e.g. `fs ls /nonexistent`) before
# falling into the empty-entries "No elements" branch — without
# this, an error from the kernel was hidden behind a misleading
# "No elements at …" message. (Codex P2 R4 on commit 5790651.)
if "error" in result:
click.echo(result["error"], err=True)
return
entries = result.get("entries", [])
if not entries:
click.echo(f"No elements at {path or sess.working_dir}")
@@ -273,6 +280,13 @@ def fs_grep(pattern, path):
if _json_output:
output(result)
else:
# Surface DOMShell errors before the empty-matches "No matches"
# branch — same fix shape as fs_ls. Without this, an anchor cd
# failure on rooted grep was hidden behind "No matches".
# (Codex P2 R4 on commit 5790651.)
if "error" in result:
click.echo(result["error"], err=True)
return
matches = result.get("matches", [])
if not matches:
click.echo(f"No matches for '{pattern}'")
@@ -304,7 +318,7 @@ def act_click(path):
"""Click an element at the given path."""
sess = get_session()
use_daemon = sess.daemon_mode
result = backend.click(path, use_daemon=use_daemon)
result = backend.click(path, use_daemon=use_daemon, session=sess)
output(result, f"Clicked: {path}")
@@ -316,7 +330,7 @@ def act_type(path, text):
"""Type text into an input element."""
sess = get_session()
use_daemon = sess.daemon_mode
result = backend.type_text(path, text, use_daemon=use_daemon)
result = backend.type_text(path, text, use_daemon=use_daemon, session=sess)
output(result, f"Typed into: {path}")
@@ -32,7 +32,7 @@ def list_elements(session: "Session", path: str = "") -> dict:
"""
target_path = path if path else session.working_dir
use_daemon = session.daemon_mode
return backend.ls(target_path, use_daemon=use_daemon)
return backend.ls(target_path, use_daemon=use_daemon, session=session)
def change_directory(session: "Session", path: str) -> dict:
@@ -69,7 +69,7 @@ def change_directory(session: "Session", path: str) -> dict:
path = session.working_dir.rstrip("/") + "/" + path
use_daemon = session.daemon_mode
result = backend.cd(path, use_daemon=use_daemon)
result = backend.cd(path, use_daemon=use_daemon, session=session)
# Only update working_dir if backend succeeded
if isinstance(result, dict) and "error" not in result:
new_working_dir = result.get("path", path)
@@ -93,7 +93,7 @@ def read_element(session: "Session", path: str = "") -> dict:
"""
target_path = path if path else session.working_dir
use_daemon = session.daemon_mode
return backend.cat(target_path, use_daemon=use_daemon)
return backend.cat(target_path, use_daemon=use_daemon, session=session)
def grep_elements(session: "Session", pattern: str, path: str = "") -> dict:
@@ -113,16 +113,11 @@ def grep_elements(session: "Session", pattern: str, path: str = "") -> dict:
"""
target_path = path if path else session.working_dir
use_daemon = session.daemon_mode
# DOMShell's grep searches from the server-side CWD. To root the search
# at the requested path, cd there first, grep, then restore.
if target_path and target_path != "/":
cd_result = backend.cd(target_path, use_daemon=use_daemon)
if hasattr(cd_result, 'isError') and cd_result.isError:
return cd_result
try:
return backend.grep(pattern, use_daemon=use_daemon)
finally:
if target_path and target_path != "/":
backend.cd(session.working_dir or "/", use_daemon=use_daemon)
prev = session.working_dir or "/"
return backend.grep(
pattern,
path=target_path,
prev=prev,
use_daemon=use_daemon,
session=session,
)
@@ -42,7 +42,7 @@ def open_page(session: "Session", url: str) -> dict:
raise ValueError(error_msg)
use_daemon = session.daemon_mode
result = backend.open_url(url, use_daemon=use_daemon)
result = backend.open_url(url, use_daemon=use_daemon, session=session)
session.set_url(url)
session.set_working_dir("/") # Reset to root on new page
return result
@@ -62,7 +62,7 @@ def reload_page(session: "Session") -> dict:
{"status": "reloaded", "url": "https://example.com"}
"""
use_daemon = session.daemon_mode
result = backend.reload(use_daemon=use_daemon)
result = backend.reload(use_daemon=use_daemon, session=session)
return result
@@ -80,7 +80,7 @@ def go_back(session: "Session") -> dict:
{"url": "https://previous.com", "status": "navigated"}
"""
use_daemon = session.daemon_mode
result = backend.back(use_daemon=use_daemon)
result = backend.back(use_daemon=use_daemon, session=session)
# Update session state if backend returned a URL
if isinstance(result, dict) and "url" in result:
@@ -103,7 +103,7 @@ def go_forward(session: "Session") -> dict:
{"url": "https://next.com", "status": "navigated"}
"""
use_daemon = session.daemon_mode
result = backend.forward(use_daemon=use_daemon)
result = backend.forward(use_daemon=use_daemon, session=session)
# Update session state if backend returned a URL
if isinstance(result, dict) and "url" in result:
@@ -21,6 +21,11 @@ class Session:
- history: Stack of URLs for back navigation
- forward_stack: Stack of URLs for forward navigation
- daemon_mode: Whether persistent daemon connection is active
- domshell_lane_id: The DOMShell 2.x lane (Chrome tab-group) the harness
has been pinned to. ``None`` until the first ``_call_execute`` reply
arrives carrying a ``[lane: <id>]`` marker; thereafter passed as
``group_id`` on every subsequent call so browser state (current tab,
cwd, focus) persists across REPL commands in non-daemon mode.
"""
current_url: str = ""
@@ -28,6 +33,7 @@ class Session:
history: list[str] = field(default_factory=list)
forward_stack: list[str] = field(default_factory=list)
daemon_mode: bool = False
domshell_lane_id: Optional[str] = None
def set_url(self, url: str, record_history: bool = True) -> None:
"""Set the current URL and update history.
@@ -95,4 +101,5 @@ class Session:
"history_length": len(self.history),
"forward_stack_length": len(self.forward_stack),
"daemon_mode": self.daemon_mode,
"domshell_lane_id": self.domshell_lane_id,
}
@@ -216,7 +216,7 @@ class TestFsModule:
result = fs.list_elements(sess)
mock_ls.assert_called_once_with("/main", use_daemon=False)
mock_ls.assert_called_once_with("/main", use_daemon=False, session=sess)
def test_list_elements_with_path(self):
"""Listing elements with explicit path overrides working_dir."""
@@ -228,7 +228,7 @@ class TestFsModule:
result = fs.list_elements(sess, "/div")
mock_ls.assert_called_once_with("/div", use_daemon=False)
mock_ls.assert_called_once_with("/div", use_daemon=False, session=sess)
def test_list_elements_empty_path_uses_working_dir(self):
"""Listing with empty path uses session working_dir."""
@@ -240,7 +240,7 @@ class TestFsModule:
result = fs.list_elements(sess, "")
mock_ls.assert_called_once_with("/main", use_daemon=False)
mock_ls.assert_called_once_with("/main", use_daemon=False, session=sess)
def test_change_directory_absolute_path(self):
"""Changing to absolute path updates working_dir."""
@@ -252,7 +252,7 @@ class TestFsModule:
result = fs.change_directory(sess, "/main")
assert sess.working_dir == "/main"
mock_cd.assert_called_once_with("/main", use_daemon=False)
mock_cd.assert_called_once_with("/main", use_daemon=False, session=sess)
def test_change_directory_relative_parent(self):
"""Changing to .. goes up one level."""
@@ -265,7 +265,7 @@ class TestFsModule:
result = fs.change_directory(sess, "..")
assert sess.working_dir == "/main"
mock_cd.assert_called_once_with("/main", use_daemon=False)
mock_cd.assert_called_once_with("/main", use_daemon=False, session=sess)
def test_change_directory_parent_from_root(self):
"""Changing to .. from root stays at root."""
@@ -300,7 +300,7 @@ class TestFsModule:
result = fs.change_directory(sess, "div[0]")
assert sess.working_dir == "/main/div[0]"
mock_cd.assert_called_once_with("/main/div[0]", use_daemon=False)
mock_cd.assert_called_once_with("/main/div[0]", use_daemon=False, session=sess)
def test_read_element(self):
"""Reading element calls backend."""
@@ -315,7 +315,7 @@ class TestFsModule:
result = fs.read_element(sess, "/main/button[0]")
mock_cat.assert_called_once_with("/main/button[0]", use_daemon=False)
mock_cat.assert_called_once_with("/main/button[0]", use_daemon=False, session=sess)
def test_read_element_empty_path_uses_working_dir(self):
"""Reading with empty path uses session working_dir."""
@@ -327,11 +327,11 @@ class TestFsModule:
result = fs.read_element(sess, "")
mock_cat.assert_called_once_with("/main", use_daemon=False)
mock_cat.assert_called_once_with("/main", use_daemon=False, session=sess)
def test_grep_elements(self):
"""Grepping calls backend with pattern."""
sess = Session()
"""Grepping calls backend with pattern and session cwd as path."""
sess = Session() # working_dir defaults to "/"
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep:
mock_grep.return_value = {
@@ -340,23 +340,22 @@ class TestFsModule:
result = fs.grep_elements(sess, "Login")
mock_grep.assert_called_once_with("Login", use_daemon=False)
mock_grep.assert_called_once_with(
"Login", path="/", prev="/", use_daemon=False, session=sess
)
def test_grep_elements_with_path(self):
"""Grepping with path cds to that path first, then restores."""
"""Grepping with an explicit path forwards it to backend.grep."""
sess = Session()
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep, \
patch("cli_anything.browser.core.fs.backend.cd") as mock_cd:
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep:
mock_grep.return_value = {"matches": ["/main/button[0]"]}
mock_cd.return_value = {"path": "/main"}
result = fs.grep_elements(sess, "Login", "/main")
mock_grep.assert_called_once_with("Login", use_daemon=False)
assert mock_cd.call_count == 2
mock_cd.assert_any_call("/main", use_daemon=False)
mock_cd.assert_any_call("/", use_daemon=False)
mock_grep.assert_called_once_with(
"Login", path="/main", prev="/", use_daemon=False, session=sess
)
# ── Daemon Mode Tests ────────────────────────────────────────────
@@ -374,7 +373,7 @@ class TestDaemonMode:
result = fs.list_elements(sess)
mock_ls.assert_called_once_with("/", use_daemon=True)
mock_ls.assert_called_once_with("/", use_daemon=True, session=sess)
def test_normal_mode_does_not_use_daemon(self):
"""Commands don't use daemon mode when session.daemon_mode is False."""
@@ -386,4 +385,57 @@ class TestDaemonMode:
result = fs.list_elements(sess)
mock_ls.assert_called_once_with("/", use_daemon=False)
mock_ls.assert_called_once_with("/", use_daemon=False, session=sess)
# ── CLI-layer error surfacing (Codex P2 R4) ─────────────────────────
class TestCLIErrorSurfacing:
"""The non-JSON branches of `fs ls` and `fs grep` previously fell
straight into ``result.get("entries"/"matches", [])`` and surfaced
"No elements at …" / "No matches for …" for DOMShell errors. Codex
P2 R4 on PR #308 commit 5790651 required surfacing the error
message instead.
"""
def _invoke(self, mod_target, error_result, argv):
"""Mock the dependency check + the fs_mod target, invoke CLI."""
from click.testing import CliRunner
from cli_anything.browser.browser_cli import cli
with patch(
"cli_anything.browser.browser_cli.backend.is_available",
return_value=(True, "ok"),
), patch(
f"cli_anything.browser.browser_cli.fs_mod.{mod_target}",
return_value=error_result,
):
return CliRunner().invoke(cli, argv)
def test_fs_ls_surfaces_error_in_non_json_output(self):
"""`fs ls` on a path DOMShell errors against should display the
error message — not the misleading "No elements at <path>".
"""
error_result = {
"error": "ls: nonexistent: No such directory",
"output": "ls: nonexistent: No such directory",
}
result = self._invoke(
"list_elements", error_result, ["fs", "ls", "/nonexistent"],
)
# click.echo(err=True) goes to stderr; CliRunner captures both.
assert "No such directory" in result.output
assert "No elements" not in result.output
def test_fs_grep_surfaces_error_in_non_json_output(self):
"""Mirror for `fs grep`. Codex P2 R4 regression test."""
error_result = {
"error": "cd: /nonexistent: No such directory",
"output": "cd: /nonexistent: No such directory",
}
result = self._invoke(
"grep_elements", error_result, ["fs", "grep", "Login", "/nonexistent"],
)
assert "No such directory" in result.output
assert "No matches" not in result.output
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff