Skip to content

Find Chrome beyond fixed paths; add eval/screenshot/wait/navigate verbs - #3

Open
sai-v-ch wants to merge 2 commits into
captivus:mainfrom
sai-v-ch:feat/agent-ergonomics-verbs
Open

Find Chrome beyond fixed paths; add eval/screenshot/wait/navigate verbs#3
sai-v-ch wants to merge 2 commits into
captivus:mainfrom
sai-v-ch:feat/agent-ergonomics-verbs

Conversation

@sai-v-ch

@sai-v-ch sai-v-ch commented Aug 19, 2026

Copy link
Copy Markdown

Four changes, all from friction hit while driving chrome-agent through a real session (setting the tool up on a fresh Ubuntu box, then using it to research an article and drive a browser-based HDL playground).

Every console block below is a verbatim transcript, captured from this branch against Chrome 151 on Linux.

1. Chrome discovery beyond the five hardcoded paths

find_chrome_binary() checked five absolute paths and nothing else — no $PATH, no env var, no flag. On a machine where Chrome lives anywhere else (Chrome for Testing, a user-local install, /usr/local/bin, Homebrew, Nix), launch fails with "not found" on a system that has Chrome.

Discovery now falls back to a $PATH lookup after the absolute paths, and --binary PATH / $CHROME_AGENT_BINARY name a browser explicitly.

An override is authoritative — a bad path errors and names itself rather than silently launching a different browser than the caller asked for. Falling back there would be worse than failing: fingerprint and profile expectations silently differ from what was requested.

$ chrome-agent launch --headless --binary /tmp/fakechrome/my-chrome
{"name": "chrome-agent-01", "port": 9223, "pid": 36685, "browser_version": "Chrome/151.0.7922.169"}

$ chrome-agent launch --headless --binary /tmp/nope
Error: Chrome/Chromium not found. Searched:
  /tmp/nope
$ echo $?
1

$CHROME_AGENT_BINARY takes the same path when no flag is given. Existing behaviour is unchanged when neither override is set: the platform's absolute paths still win, so a $PATH shim can never displace a system install.

2. wait — an event barrier instead of sleep

The README tells agents on non-Monitor harnesses not to fall back to fixed sleeps, then ships the answer as scripts/cdp-wait.py, which isn't in the package. Without a CLI verb, the practical result is sleep 5 between every step (which is what I ended up writing, repeatedly, before this).

$ chrome-agent wait proj-01 Page.loadEventFired --timeout 25   # navigation ran in another shell
{"method": "Page.loadEventFired", "params": {"timestamp": 5002.763586}}
$ echo $?
0

$ chrome-agent wait proj-01 Page.loadEventFired --timeout 3    # quiet page
Timed out after 3s waiting for: Page.loadEventFired
$ echo $?
1

--contains SUBSTRING narrows a match to events whose JSON holds every given substring, and the +Event form is accepted so attach and wait subscribe alike.

Scope is stated in the docs rather than fudged: wait opens its own session, so it can only match events that fire after it subscribes — scripts/cdp-wait.py over a backgrounded attach stream remains the answer for events that may fire first.

3. navigate — a load barrier and the HTTP status

Page.navigate returns {frameId, loaderId, isDownload} at commit time. It carries no status, so a 404 or a bot-block page is indistinguishable from real content until you inspect the DOM, and any following command races the load.

This cost me six wrong guesses in one session: probing category slugs to find an article, every miss returned a plausible-looking result and a page whose document.title was just the site name.

$ chrome-agent navigate proj-01 https://example.com
{"url": "https://example.com/", "status": 200, "frameId": "D795C6AD...", "loaderId": "0C6789BA...", "loaded": true, "elapsedMs": 95}

$ chrome-agent navigate proj-01 https://example.com/definitely-not-a-real-slug/
{"url": "https://example.com/definitely-not-a-real-slug/", "status": 404, "frameId": "D795C6AD...", "loaderId": "B5A82F38...", "loaded": true, "elapsedMs": 45}

$ chrome-agent navigate proj-01 https://this-host-does-not-exist-zzz.invalid/
Error: net::ERR_NAME_NOT_RESOLVED (https://this-host-does-not-exist-zzz.invalid/)
$ echo $?
1

Same shape from Page.navigate for the first two; different status, which is the whole point. (Frame and loader ids truncated here for width; the real output prints them in full.)

--wait load|domcontentloaded|none. --wait none reports "loaded": null rather than claiming a load it never observed; a load that doesn't finish within --timeout prints the result and exits 1.

4. eval and screenshot — the plumbing tax

Runtime.evaluate means JavaScript inside a JSON string inside a shell argument. Anything non-trivial dies of quoting — during the session that produced this PR I abandoned the shell entirely and drove the CLI from a Python wrapper just to build the arguments. Page.captureScreenshot returns base64 that every caller decodes by hand.

$ chrome-agent eval proj-01 'document.title'
Example Domain

$ chrome-agent eval proj-01 --file probe.js       # multi-line JS, no escaping
{
  "title": "Example Domain",
  "links": 1,
  "url": "https://example.com/"
}

$ echo 'document.readyState' | chrome-agent eval proj-01 -
complete

$ chrome-agent eval proj-01 'throw new Error("boom")'
Page error: Error: boom
    at <anonymous>:1:7 (line 1)
$ echo $?
1

$ chrome-agent screenshot proj-01 -o /tmp/page.png --full-page
/tmp/page.png
$ chrome-agent screenshot proj-01 -o /tmp/h1.png --selector 'h1'
/tmp/h1.png

eval prints the value — string as itself, anything else as JSON — because result.result.value is essentially never what the caller wants; --json keeps the full envelope. Promises are awaited. A thrown page exception goes to stderr and exits 1, so an error can't be mistaken for a result.

screenshot prints the path it wrote (a human-readable line with a byte count when stdout is a TTY).

These are wrappers, not a layer

Nothing here gates the protocol. Every verb is a thin composition of the same primitives, the raw <instance> Domain.method form still reaches everything, and no new schema validation is introduced — the "tracks the running browser, not its own version" property is untouched.

Structural note

page_ops.attached_page_session is now the single place that resolves a page target and attaches a flattened session, and the existing one-shot path was migrated onto it rather than left as a parallel copy — that's most of the churn in cli.py, and it means every entry point shares identical target-selection and isolation semantics.

One consequence worth reviewing: resolve_port reaches the registry through the module (registry.lookup) rather than a from-import, so test_one_shot_ambiguous_target_clean_error, which monkeypatches chrome_agent.registry.lookup, keeps working unchanged.

Testing

190 passed — the 162 existing tests plus 28 new ones (22 in tests/test_page_ops.py, 6 discovery tests in tests/test_launcher.py).

Two existing stubs were updated for the new find_chrome_binary signature (lambda: Nonelambda binary=None: None) in tests/test_launcher.py and tests/test_cli.py.

New tests assert observable contracts, not plumbing: a bad --binary doesn't fall back; the $PATH fallback never displaces a system install; eval unwraps values, awaits promises, and raises on page exceptions; --selector produces an image of exactly the element's size (120×60 for a 120×60 div); --full-page is taller than the viewport capture; wait returns the event, and None on timeout; navigate reports status 200, returns "loaded": null for --wait none, and raises on a refused navigation.

Every verb was also exercised outside the suite against a real headed Chrome 151, which is where the transcripts above come from.

Docs

README.md and AGENTS.md updated. Since src/chrome_agent/AGENTS.md symlinks to the root guide, chrome-agent guide ships the new verbs automatically. The screenshot entry in the guide's "Commands and what they return" section now points at the verb instead of the hand-rolled base64 decode one-liner.

Browser discovery was five hardcoded absolute paths, so any install
outside them -- Chrome for Testing, a user-local build, /usr/local/bin,
Homebrew, Nix -- was unreachable and launch failed with "not found" on a
machine that had Chrome. Discovery now falls back to a $PATH lookup, and
--binary / $CHROME_AGENT_BINARY name a browser explicitly. An override is
authoritative: a bad path errors and names itself rather than silently
launching a different browser than the caller asked for.

Add four verbs over the existing one-shot channel, for the operations
that are painful expressed raw:

  eval       JS from an argument, --file, or stdin -- no JSON-inside-shell
             quoting -- printing the value rather than the CDP envelope.
             Promises are awaited; a page exception exits 1.
  screenshot decodes the base64 and writes the file, with --full-page and
             --selector clipping.
  wait       blocks until a CDP event fires, replacing the fixed sleeps
             the guide already tells agents not to use. scripts/cdp-wait.py
             stays the answer for events that may fire before the wait.
  navigate   waits for the load and reports the main document's HTTP
             status, which Page.navigate never returns -- a 404 and a 200
             are otherwise indistinguishable without inspecting the DOM.

These are wrappers, not a layer: the raw form still reaches every method,
and nothing is validated against a bundled schema.

Target attachment is now shared: page_ops.attached_page_session is the one
place that resolves a target and attaches a flattened session, and the
one-shot path uses it too, so every entry point has identical selection
and isolation semantics.
An unregistered leading token stays in the verb's argument list, so the
expression or URL is consumed as the verb's own argument and the next
token trips the unknown-option branch -- reporting a bad expression when
the actual cause is a mistyped or already-stopped instance name.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant