A browser tool for AI agents, written in Rust. It drives Chromium over the DevTools protocol (CDP) and serves its tools as MCP over stdio.
- Ten tools:
open,tree,tap,type,upload,download,screenshot,logs,get,check. No tool runs agent-written JavaScript. - Settles by itself: every action waits until the network and the DOM go quiet, so the agent does not add sleeps.
- Refusals that say what to do: a refused call returns
error(a class to branch on),reasonandnext. - Write check on every call: requests other than GET, HEAD and OPTIONS from the driven tab are blocked unless the operator allowed them; each result lists the writes it blocked. See Security model and limits for what it does not cover.
- Evidence: every call goes to a JSONL journal with time and duration; screenshots and downloads are saved with their SHA-256.
- Replay without a model: a journal is also a script. Replay reads only by default and stops with a report at the first failed step.
- Live view with takeover: an MJPEG stream on 127.0.0.1; a human can take over the browser, and agent calls wait until it is released.
- No daemon: Chromium starts with
--remote-debugging-port=0; a browser already running with the same profile is reused.
cargo build --releaseNeeds Rust 1.85 or newer and a Chromium-based browser (chromium,
google-chrome, or the path in the config). Tested on Linux only; on other
systems set chromium to the browser's path.
rustglance login --config agent.json # a human logs in once, in a window
rustglance mcp --config agent.json # MCP server for the agentlogin is needed only for sites behind a login. For public sites skip it and
run mcp directly; the agent browses as a visitor without an account.
An MCP client entry:
{ "mcpServers": { "browser": { "command": "rustglance", "args": ["mcp", "--config", "/path/agent.json"] } } }Agents never log in. When a site answers 401 or redirects to its login page,
calls are refused with logged_out and a human runs rustglance login again.
skills/rustglance/SKILL.md is an agent skill with the working loop, data
reading, refusal handling and evidence; copy it to your agent's skills folder.
JSON, every field optional. Keep it, the profile and the evidence outside any repository: the profile holds the logged-in session.
{
"profile": "/home/me/.local/share/rustglance/profile",
"start_url": "https://app.example.com",
"evidence": "/home/me/rustglance-evidence",
"upload_dir": "/home/me/rustglance-uploads",
"headless": true,
"write_block": ["*"],
"write_allow": ["https://app.example.com/api/search*"],
"writes": false,
"logged_out_url": "/login",
"view_port": 9333
}| field | default | meaning |
|---|---|---|
profile |
~/.local/share/rustglance/profile |
Chromium user data dir |
chromium |
searched in PATH | browser binary |
headless |
true |
no window (login always opens one) |
webgl |
false |
WebGL without a window; headless Chromium draws it on the CPU |
start_url |
none | opened at start; base for relative paths |
evidence |
~/.local/share/rustglance/evidence |
journals, shots/, downloads/ |
upload_dir |
none (uploads off) | the only folder upload reads |
write_block |
["*"] |
URL globs whose writes are checked |
write_allow |
[] |
URL globs where writes pass |
writes |
false |
allow every write |
logged_out_url |
none | URL part that means "logged out" |
cdp_timeout_ms |
15000 |
a page that does not answer in time is replaced |
settle_ms |
8000 |
longest wait for the page to go quiet |
download_ms |
120000 |
longest wait for a download |
retries |
4 |
retries of get on 429 and 503 |
get_max_bytes |
16384 |
largest get body handed to the agent; some MCP clients cut longer output; 0 hands every body over |
view_port |
none (off) | live view port on 127.0.0.1; if the view cannot start, tools still work |
window |
[1280, 900] |
window size |
chromium_args |
[] |
extra browser flags |
Every tab rustglance attaches to is brought to front and sees prefers-reduced-motion: reduce, so pages that honour it stop looping animations instead of drawing them on the CPU.
open and tap accept wait_for. Without it, the existing network/DOM
quiet wait is unchanged. With it, RG waits for useful content instead of
unrelated background traffic, and requires the matched text/count to remain
stable for 200 ms. A text-only condition must remain true for 200 ms; it does not assert stability of surrounding content. Write blocking and refusals are unchanged.
{"url":"https://example.org/results","wait_for":{"selector":"main article h2","min_count":3,"min_text_length":60,"url":"/results","timeout_ms":8000}}The condition needs nonempty text or a CSS selector; optional url
is a substring check. All supplied conditions must hold. min_count defaults
to 1, min_text_length to 0 and timeout_ms to 8000 (maximum 120000).
Counts and text length apply to visible matched nodes in the main document.
Selectors do not execute agent JavaScript. Invalid CSS is refused before
the navigation/click. A missing or unstable condition returns timeout;
the action may already have happened, so inspect before repeating it.
In condition mode, settled is null and readiness.ready reports success;
network_idle_checked:false explicitly means global quiet was not checked.
timeout_ms bounds the condition wait after the action, not total navigation
or click time. A short condition timeout does not trigger tab recovery.
Choose a condition specific to the destination/result, especially for tap:
generic text already present before a click cannot prove a transition.
Conditions are readiness evidence, not proof of authentication or a saved
business transaction. Main-document CSS does not cover iframe contents.
tree itself remains an immediate observation and does not wait for readiness.
logs now includes kind:"settle" entries for the default wait, with
network_ms, dom_ms, total_ms, phase, and ready to distinguish delays.
kind:"network_quiet" also reports peak in-flight count and aged-out
in-flight requests. These counters diagnose waiting, not site correctness.
| tool | what it does |
|---|---|
open |
go to a URL (or a path resolved against the page) and settle |
tree |
page outline: refs like e12 for interactive elements, headings, text, groups; find filters lines |
tap |
click by ref or visible text; refuses when something covers the target |
type |
type into a field (replaces by default), pick a select option, or press a key |
upload |
put a file from upload_dir into a file input |
download |
click or fetch a download; saved with size and SHA-256 |
screenshot |
PNG to the evidence folder; look: true returns the image |
logs |
console, page errors, failed requests, HTTP errors, writes (allowed true or false) |
get |
HTTP GET with the browser's login; JSON body, fields to keep only some keys of list items; a body over get_max_bytes keeps the first list items (truncated) and is saved whole (file); retries 429 and 503 |
check |
wait until text shows, text goes, the URL matches, or a ref exists; timeout_ms up to 120000 |
Refusal classes: bad_args, not_found, stale, covered, logged_out,
rate_limited, outside_folder, timeout, wedged, page_error, site,
browser, human_control, io.
type refuses disabled/read-only/inert controls and disabled native select
options (including disabled optgroups). It does not force a value past these
UI restrictions. Native selects accept an observed option label/value.
tree keeps prose paragraphs/cells with inline links in reading order and
lists actionable link refs underneath. This avoids moving linked words to
the end of a sentence; large articles may produce more text. Prefer find
or within when only a section is needed. A filtered tree is not a full-page
completeness guarantee.
Each mcp run writes evidence/journal-<time>.jsonl, one line per call:
time, tool, arguments, duration, result or refusal, blocked writes.
rustglance report journal.jsonl # markdown summary
rustglance script run1.jsonl run2.jsonl > s.jsonl
rustglance replay s.jsonl --param p1=value # reads only
rustglance replay s.jsonl --writes # also writesscript with two runs of the same task turns string arguments that differ
into {{p1}}, {{p2}}... and prints the values seen. Replay skips tools that
write and blocks every network write unless --writes. It prints nothing per
step; at the first failed step (refusal, check false, or a tap that hit a
different element than in the recording) it stops and writes
replay-<time>-failure.md with a screenshot and the browser log.
With view_port set, open http://127.0.0.1:<port>/. "Take over" refuses
agent calls with human_control and sends your clicks, wheel and keys to the
page; "Release" gives control back. Refs from before the takeover are refused
with stale until the agent calls tree again without find or within.
It listens on 127.0.0.1 only and checks
the Host and Origin headers; any local process can still reach it. Write
blocking stays on during a takeover, so a save you click is blocked too unless
the config allows it.
rustglance keeps an agent from saving data by mistake. It is not a sandbox against a hostile page or a hostile local user.
- Write blocking covers the one tab rustglance drives. Tabs and popups the page opens, cross-origin iframes that run in their own process, and workers (dedicated, shared and service workers) are not attached, so their requests are not checked. WebSocket messages are not checked either.
- GET, HEAD and OPTIONS always pass, so a site that changes data on GET is not protected. Nothing limits which hosts the agent opens: a page with injected instructions can make the agent open a URL that carries data out. Blocking writes does not stop data from leaving.
- Chromium's DevTools port listens on 127.0.0.1 without a password while the browser runs. Any local process, of any user, can drive the logged-in browser. Run it on a machine you trust.
- The journal, screenshots and downloads are written with your default file
permissions (umask), and the journal holds every argument, including text
the agent typed.
typerefuses password fields and keeps that attempt's text out of the journal. Results are stored with values under names such astoken,csrforcookieblanked, but other secrets in a page or a response are not detected. Keep the evidence folder private.
Add tools in your own binary and keep the core as a dependency:
fn main() -> std::process::ExitCode {
let mut ops = rustglance::ops::core();
ops.extend(my_tools());
rustglance::cli::main_with(ops)
}An OpDef with writes: true is skipped by replay unless --writes.
Ctx::permit(pattern) lets one call write to matching URLs.
MIT or Apache-2.0, at your option.