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
15 changes: 8 additions & 7 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,18 @@ beta.

### 5. Drive a browser from an agent (CLI)

Installing Rustwright also installs `rustwright-agent`, a CLI that keeps one
browser alive across commands:
The `rustwright` CLI keeps one browser alive across agent commands:

```bash
rustwright-agent open example.com # launch + navigate; prints an accessibility snapshot
rustwright-agent snapshot # accessibility tree with refs (e1, e2, …)
rustwright-agent click e3 # act on an element by its ref
rustwright-agent --json snapshot # one JSON object, for scripting
rustwright-agent close # shut the session down
rustwright open example.com # launch + navigate; prints an accessibility snapshot
rustwright snapshot # accessibility tree with refs (e1, e2, …)
rustwright click e3 # act on an element by its ref
rustwright --json snapshot # one JSON object, for scripting
rustwright close # shut the session down
```

The CLI verbs and the MCP server's tools are the same surface.

See [docs/agent-interfaces.md](docs/agent-interfaces.md) for the CLI verbs,
configuration, threat model, and current scope. An MCP server for Rustwright is
available as a separate, opt-in package (`rustwright-mcp`).
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ take a snapshot, then act on an element by its ref. Refs are session-scoped and
never reused; resolution is best-effort for cooperative pages (not a security
boundary), and snapshots reflect page values with password fields masked.

### CLI — `rustwright-agent`
### CLI — drive a browser from your shell

Ships with the `rustwright` package (pure Python, no extra runtime dependency).
Named sessions keep one browser alive across commands:
Expand All @@ -109,14 +109,16 @@ Named sessions keep one browser alive across commands:
pip install rustwright
python -m rustwright install chromium # one-time browser download

rustwright-agent open example.com # launch + navigate; prints a snapshot
rustwright-agent snapshot # accessibility tree with refs (e1, e2, …)
rustwright-agent click e3 # act on an element by its ref
rustwright-agent fill e2 "hi@example.com"
rustwright-agent --json snapshot # one JSON object, for scripting
rustwright-agent close
rustwright open example.com # launch + navigate; prints a snapshot
rustwright snapshot # accessibility tree with refs (e1, e2, …)
rustwright click e3 # act on an element by its ref
rustwright fill e2 "hi@example.com"
rustwright --json snapshot # one JSON object, for scripting
rustwright close
```

The CLI verbs and the MCP server's tools are the same surface.

Full verbs, flags, session model, and threat model:
[docs/agent-interfaces.md](docs/agent-interfaces.md).

Expand Down
54 changes: 31 additions & 23 deletions docs/agent-interfaces.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# Agent CLI

`rustwright-agent` is a command-line interface for driving a Chromium browser
over CDP from an AI agent or a shell. It is pure Python, adds no third-party
runtime dependencies, and is installed with the package. Named sessions keep one
browser alive across invocations, so a sequence of commands drives a single
browser.
`rustwright` is a command-line interface for driving a Chromium browser over CDP
from an AI agent or a shell. It is pure Python, adds no third-party runtime
dependencies, and is installed with the package. Named sessions keep one browser
alive across invocations, so a sequence of commands drives a single browser.

It is Chromium-only (Rustwright automates Chromium over CDP).

Expand Down Expand Up @@ -56,11 +55,11 @@ snapshot output as potentially sensitive.
## Using it

```bash
rustwright-agent open https://example.com # launch + navigate; prints a snapshot
rustwright-agent snapshot # accessibility tree with refs
rustwright-agent click e3 # click by ref
rustwright-agent fill e2 "user@example.com" # fill by ref
rustwright-agent close # shut the session down
rustwright open https://example.com # launch + navigate; prints a snapshot
rustwright snapshot # accessibility tree with refs
rustwright click e3 # click by ref
rustwright fill e2 "user@example.com" # fill by ref
rustwright close # shut the session down
```

Chromium must be available. If you have not already installed a browser for
Expand All @@ -84,27 +83,27 @@ If a command process is interrupted mid-action, the session records that it may
be mid-change; the next command clears its refs and re-snapshots before acting,
so you never act on a ref from an uncertain state.

The persistent CLI is supported on macOS and Linux.
Persistent sessions require macOS or Linux; non-session commands remain available on Windows.

### Verbs

| Verb | Example | Notes |
|---|---|---|
| `open [URL]` | `rustwright-agent open example.com` | Start/attach a session; optional navigate. |
| `navigate URL` | `rustwright-agent navigate example.com` | Navigate the active tab. |
| `open [URL]` | `rustwright open example.com` | Start/attach a session; optional navigate. |
| `navigate URL` | `rustwright navigate example.com` | Navigate the active tab. |
| `back` / `reload` | | History + reload. |
| `snapshot` | `rustwright-agent snapshot --depth 6` | Print the accessibility tree with refs. |
| `click REF` | `rustwright-agent click e3` | Click by ref. |
| `fill REF TEXT` | `rustwright-agent fill e2 hello` | Clear and fill (text is not echoed back). |
| `snapshot` | `rustwright snapshot --depth 6` | Print the accessibility tree with refs. |
| `click REF` | `rustwright click e3` | Click by ref. |
| `fill REF TEXT` | `rustwright fill e2 hello` | Clear and fill (text is not echoed back). |
| `type REF TEXT` | | Type with optional `--delay-ms`. |
| `select REF VALUE…` | | Select `<option>` values. |
| `hover REF` / `press KEY` | | Hover / keyboard press. |
| `wait …` | `rustwright-agent wait --text Loaded` | Wait for time / text / text-gone / load state. |
| `tabs …` | `rustwright-agent tabs new example.com` | `list` / `new` / `use` / `close`. |
| `screenshot [PATH]` | `rustwright-agent screenshot shot.png --full` | Save a screenshot. |
| `status` | `rustwright-agent status` | Show whether a session is running (endpoint redacted). |
| `close [--force]` | `rustwright-agent close --force` | Shut down; `--force` also clears a wedged session. |
| `eval EXPR` | `rustwright-agent --allow-eval eval "document.title"` | Requires `--allow-eval`. |
| `wait …` | `rustwright wait --text Loaded` | Wait for time / text / text-gone / load state. |
| `tabs …` | `rustwright tabs new example.com` | `list` / `new` / `use` / `close`. |
| `screenshot [PATH]` | `rustwright screenshot shot.png --full` | Save a screenshot. |
| `status` | `rustwright status` | Show whether a session is running (endpoint redacted). |
| `close [--force]` | `rustwright close --force` | Shut down; `--force` also clears a wedged session. |
| `eval EXPR` | `rustwright --allow-eval eval "document.title"` | Requires `--allow-eval`. |

Global flags include `--session NAME` (default `default`), `--json` (emit one
JSON object per command), `--timeout-ms`, `--navigation-timeout-ms`, `--headed`,
Expand All @@ -130,7 +129,7 @@ Errors print `error[code]: message` on standard error.

- **No browser found** — run `rustwright install chromium` once.
- **`session_lost`** — the owner process is gone or the endpoint is unreachable.
Run `rustwright-agent close --force` to clear the session, then `open` again.
Run `rustwright close --force` to clear the session, then `open` again.
- **`session_busy`** — another command holds the session lock; retry after it
finishes.
- **Stale ref** — take a new `snapshot` and use a ref from it.
Expand All @@ -140,3 +139,12 @@ Errors print `error[code]: message` on standard error.
`find`/`read` helpers, batch execution, cookie/storage/network/console tools,
PDF export, file upload, viewport resize, child-frame and shadow-DOM refs, and
Windows support for the persistent CLI are planned follow-ups.

## Differences from the playwright CLI

`rustwright open` starts a persistent session; pass `--headed` before `open` for
a visible window, and run `rustwright close` to end the session. `screenshot`
keeps the two-argument, one-shot `screenshot <url> <file>` form and adds a
session form, `screenshot [file]`. Emulation flags such as `--device` remain on
the one-shot `screenshot` and `pdf` commands but no longer exist on `open`; use
the Python API when full emulation is required.
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ features = ["pyo3/extension-module"]

[project.scripts]
rustwright = "rustwright.cli:main"
rustwright-agent = "rustwright._agent.cli:main"

[project.entry-points.pytest11]
rustwright = "rustwright.pytest_plugin"
Expand Down
37 changes: 35 additions & 2 deletions python/rustwright/_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import errno
from importlib import metadata
import ipaddress
import json
import os
Expand All @@ -25,6 +26,7 @@
launch_config_hash,
mark_dirty,
owner_lock_is_held,
persistent_sessions_supported,
read_json,
read_state,
remove_session_files,
Expand Down Expand Up @@ -59,6 +61,13 @@
_SNAPSHOT_REF_RESERVATION = 1000


def _version() -> str:
try:
return metadata.version("rustwright")
except metadata.PackageNotFoundError:
return "0.1.1"


class ParserExit(Exception):
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
Expand Down Expand Up @@ -108,8 +117,8 @@ def _add_wait_until(parser: argparse.ArgumentParser) -> None:


def build_parser() -> NonExitingArgumentParser:
parser = NonExitingArgumentParser(prog="rustwright-agent")
parser.add_argument("--version", action="version", version="rustwright-agent 1")
parser = NonExitingArgumentParser(prog="rustwright")
parser.add_argument("--version", action="version", version=_version())
parser.add_argument("--session", default="default")
parser.add_argument("--json", action="store_true", dest="json_output")
parser.add_argument(
Expand All @@ -130,6 +139,12 @@ def build_parser() -> NonExitingArgumentParser:
commands = parser.add_subparsers(dest="command", required=True)
open_parser = commands.add_parser("open")
open_parser.add_argument("url", nargs="?")
open_parser.add_argument(
"-b",
"--browser",
default="chromium",
help="browser to use (cr or chromium; other Chromium executables require --executable-path)",
)

navigate_parser = commands.add_parser("navigate")
navigate_parser.add_argument("url")
Expand Down Expand Up @@ -448,6 +463,19 @@ def _is_mutating(args: argparse.Namespace) -> bool:

def _validate_command(args: argparse.Namespace) -> None:
validate_session_name(args.session)
if args.command == "open":
if args.browser in {"ff", "firefox", "wk", "webkit"}:
raise AgentError(
"invalid_argument",
f"{args.browser} is not implemented; Rustwright currently supports Chromium over direct CDP.",
)
if args.browser in {"chrome", "msedge"} and args.executable_path is None:
raise AgentError(
"invalid_argument",
f"{args.browser} channel selection is not supported; use --executable-path to launch that browser.",
)
if args.browser not in {"cr", "chromium", "chrome", "msedge"}:
raise AgentError("invalid_argument", f"Unknown browser: {args.browser}")
forbidden_browser_args = (
"--remote-debugging-port",
"--remote-debugging-pipe",
Expand Down Expand Up @@ -737,6 +765,11 @@ def _success_exit(args: argparse.Namespace, data: Dict[str, Any]) -> int:


def _run(args: argparse.Namespace, argv: List[str]) -> Dict[str, Any]:
if not persistent_sessions_supported():
raise AgentError(
"unsupported_platform",
"persistent sessions require macOS or Linux",
)
_validate_command(args)
with session_lock(args.session):
if args.command == "status":
Expand Down
2 changes: 1 addition & 1 deletion python/rustwright/_agent/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def exit_code(self) -> int:
return 4
if self.code in {"session_busy", "session_lost", "session_not_found"}:
return 3
if self.code in {"invalid_argument", "invalid_request", "invalid_ref"}:
if self.code in {"invalid_argument", "invalid_request", "invalid_ref", "unsupported_platform"}:
return 2
return 1

Expand Down
2 changes: 1 addition & 1 deletion python/rustwright/_agent/owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def _reject_browser_args(values: List[str]) -> None:


def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="rustwright-agent-owner")
parser = argparse.ArgumentParser(prog="rustwright-owner")
parser.add_argument("--session", required=True)
parser.add_argument("--headed", action="store_true")
parser.add_argument("--executable-path")
Expand Down
23 changes: 16 additions & 7 deletions python/rustwright/_agent/state.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Durable, per-user state for persistent agent browser sessions."""

import errno
import fcntl
import hashlib
import json
import os
import re
import secrets
import stat
import sys
import tempfile
import time
from contextlib import contextmanager
Expand All @@ -16,6 +16,11 @@

from .errors import AgentError

try:
import fcntl as _fcntl
except ModuleNotFoundError: # pragma: no cover - exercised by an import-hook regression test
_fcntl = None


_SESSION_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_STATE_FIELDS = {
Expand All @@ -34,6 +39,10 @@
}


def persistent_sessions_supported() -> bool:
return _fcntl is not None and (sys.platform == "darwin" or sys.platform.startswith("linux"))


def validate_session_name(name: str) -> str:
if not isinstance(name, str) or _SESSION_NAME.fullmatch(name) is None:
raise AgentError(
Expand Down Expand Up @@ -359,7 +368,7 @@ def owner_lifetime_lock(name: str) -> Iterator[None]:
acquired = False
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_fcntl.flock(fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
acquired = True
except OSError as exc:
if exc.errno in (errno.EACCES, errno.EAGAIN):
Expand All @@ -369,7 +378,7 @@ def owner_lifetime_lock(name: str) -> Iterator[None]:
finally:
if acquired:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
_fcntl.flock(fd, _fcntl.LOCK_UN)
except OSError:
pass
os.close(fd)
Expand All @@ -383,7 +392,7 @@ def owner_lock_is_held(name: str) -> bool:
acquired = False
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_fcntl.flock(fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
acquired = True
return False
except OSError as exc:
Expand All @@ -393,7 +402,7 @@ def owner_lock_is_held(name: str) -> bool:
finally:
if acquired:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
_fcntl.flock(fd, _fcntl.LOCK_UN)
except OSError:
pass
os.close(fd)
Expand Down Expand Up @@ -421,7 +430,7 @@ def session_lock(name: str, timeout: float = 30.0) -> Iterator[None]:
try:
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_fcntl.flock(fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
acquired = True
break
except OSError as exc:
Expand All @@ -434,7 +443,7 @@ def session_lock(name: str, timeout: float = 30.0) -> Iterator[None]:
finally:
if acquired:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
_fcntl.flock(fd, _fcntl.LOCK_UN)
except OSError:
pass
os.close(fd)
Expand Down
Loading
Loading