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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ jobs:
components: rustfmt, clippy
- run: cargo fmt --all -- --check
- run: cargo test --all-targets --locked
- name: Verify native clicks with full headless Chrome
timeout-minutes: 5
run: |
export BROWSER_CLI_TEST_CHROME="$(command -v google-chrome)"
test -n "$BROWSER_CLI_TEST_CHROME"
"$BROWSER_CLI_TEST_CHROME" --version
cargo test --locked --test page_targets_browser -- --ignored --nocapture
- name: Verify tests ignore inherited proxy configuration
run: >-
env -u NO_PROXY -u no_proxy
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lexmount-browser"
version = "1.2.3"
version = "1.2.4"
edition = "2024"
license = "MIT"
description = "Native Rust SDK and CLI for Lexmount cloud browsers"
Expand Down
41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ write `{"ok":false,"error":"...","message":"..."}` to stderr and exit with
status 1. JavaScript evaluation failures keep the `cdp_error` category, but now
include the browser's error summary and, when provided, one-based line/column
positions. For example, a missing selector reports `Error: selector not found`
instead of only `Uncaught`. This also applies to actions implemented with
evaluation, such as `click` and `fill`; it does not retry or fix the action.
instead of only `Uncaught`. This also applies to DOM lookup/checks in `click`
and evaluation in `fill`; it does not retry or fix the action.

The summary is the first line of the exception description (up to 1024 Unicode
characters plus a truncation marker), falling back to a primitive thrown value
Expand Down Expand Up @@ -86,8 +86,8 @@ automatic action retries. These changes require a new CLI release; published
Explicit page selection is introduced in version 1.2.0. Check that the installed
binary's `browser-cli action --help` lists `--target-id`; the published 1.1.15
binary does not have it. The package version and both bootstrap scripts target
1.2.3 together. Merging or building this source does not publish release assets:
bootstrap can install 1.2.3 only after its binaries and checksums are published
1.2.4 together. Merging or building this source does not publish release assets:
bootstrap can install 1.2.4 only after its binaries and checksums are published
to COS. Until then, use a source build for local verification.

Every `action` command accepts an optional `--target-id`. Obtain the page's CDP
Expand Down Expand Up @@ -126,11 +126,35 @@ and inspect again when there are multiple plausible pages.
SDK callers can use `lexmount_browser::cdp::Cdp::connect_to_target(ws_url, page_id)`.
`Cdp::connect(ws_url)` retains its existing default behavior.

### Native click input

Starting in 1.2.4, `action click --selector CSS` (and SDK `Cdp::click`) scrolls
the element into view and sends CDP `Input.dispatchMouseEvent` move/press/release
events. This replaces JavaScript `HTMLElement.click()`, which produces an
untrusted event without user activation and can leave `window.open()` blocked.
It does not add a user gesture to arbitrary `action eval` expressions.

Before pressing, the CLI checks that the selected element is attached, enabled,
visible and hit-testable at a client-rectangle center inside the viewport.
Disabled controls/ancestors, ARIA-disabled or inert ancestors, and overlays are
rejected. It rechecks the **same DOM object and point** after hover; a replacement,
movement away from that point, or new overlay causes a `cdp_error`, not a click on
another element. There is no automatic retry or JavaScript-click fallback.
These checks are not an atomic lock against later page mutations.

The existing main-document CSS selector scope is unchanged: this does not add
iframe or shadow-root traversal. `{"ok":true,"data":true}` means input was
dispatched, **not** that the website completed the task or opened a popup. Site
logic/browser policy can still prevent an outcome. Inspect the page/targets and
explicitly select any new result tab as shown above; click never navigates to an
inferred URL or automatically switches tabs. A failed command may have partially
dispatched input; inspect state before retrying it.

### Local regression tests

```bash
cargo test --all-targets --locked
# Optional: use a local Chrome/Chromium executable, including chrome-headless-shell.
# Use full Chrome/Chromium to cover popup blocking; headless-shell alone is insufficient.
BROWSER_CLI_TEST_CHROME=/path/to/chrome cargo test --locked --test page_targets_browser -- --ignored --nocapture
```

Expand All @@ -139,6 +163,11 @@ running the same `cargo test` command. The opt-in test launches a separate
headless profile and loopback-only fixtures; it does not use a Lexmount account,
real websites, or an existing browser profile. The default suite exercises all
action routes and failure/no-fallback behavior with deterministic CDP fixtures.
CI also runs the real-browser suite with its installed full Google Chrome.
Tests assert trusted input/user activation as well as popup creation, and cover
scrolling, hidden/disabled/covered elements, hover changes and cleanup failures.
Some headless-shell builds allow untrusted popups; passing there alone does not
demonstrate the click fix.

## Agent Skill package

Expand Down Expand Up @@ -170,7 +199,7 @@ overwrite an existing release with changed binaries.
`skills/lexmount-browser/scripts/bootstrap.ps1` and `bootstrap.sh`.
2. Run `.github/scripts/test-release-version.ps1` with Windows PowerShell 5.1
or PowerShell 7, then `.github/scripts/verify-release-version.ps1 -ReleaseTag
v1.2.3` (substitute the intended version). Complete CI and merge the PR.
v1.2.4` (substitute the intended version). Complete CI and merge the PR.
3. Create the matching tag **on that merged commit**. Typing a new tag or
release title in GitHub does not update any source version. The release
workflow rejects inconsistent versions before building, signing or uploading.
Expand Down
4 changes: 2 additions & 2 deletions skills/lexmount-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ Do not run the binary for the other platform. Both platform binaries emit JSON.
## Setup

1. Resolve `<skill-root>` from this `SKILL.md` and select the matching platform paths above.
2. Run the Skill-local bootstrap script if the binary is missing. Then run `sh "<skill-root>/scripts/doctor.sh"` on macOS arm64 or `& "<skill-root>\scripts\doctor.ps1"` in Windows PowerShell.
2. Run the Skill-local bootstrap script if the binary is missing. Then run `sh "<skill-root>/scripts/doctor.sh"` on macOS arm64. On Windows, invoke the installed binary's `doctor` command directly, not `scripts/doctor.ps1`: PowerShell uses `& "<skill-root>\bin\browser-cli.exe" doctor`; Bash/Git Bash uses `"<skill-root>/bin/browser-cli.exe" doctor` with forward slashes in the absolute path. In WorkBuddy on Windows, prefer its Bash tool when available: its PowerShell tool can return only an exit code and omit the JSON. Keep the tool's normal permissions and sandbox.
3. If credentials are missing, run `browser-cli auth login`. Pass `--client-name "<agent-name>"` when the current Agent has a user-facing name; otherwise the CLI uses `Agent`. Let the user approve in their browser. Never ask them to paste an API key into chat.
4. Run `browser-cli doctor` again. Continue only when `ready_for_browser_actions` is true.
4. After changing credentials, run `browser-cli doctor` again; otherwise use the doctor result already obtained. Continue only when its actual JSON reports `ready_for_browser_actions: true`. An exit code without that JSON is not a readiness result; see [Windows diagnostic output](references/troubleshooting.md#windows-diagnostic-output).

Read [authentication.md](references/authentication.md) only when login or credentials fail. Read [commands.md](references/commands.md) when selecting commands. Read [troubleshooting.md](references/troubleshooting.md) only after an error.

Expand Down
25 changes: 25 additions & 0 deletions skills/lexmount-browser/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,29 @@ Run `browser-cli doctor` first and use the failed check's message.
- Skill root unknown: resolve the directory containing the loaded `SKILL.md` with the current host's locator: Codex supplies its absolute source path in the Skill metadata, Claude Code provides `${CLAUDE_SKILL_DIR}`, and WorkBuddy/CodeBuddy provides `${CODEBUDDY_SKILL_DIR}`. Do not infer it from the working directory or search the user's home directory.
- command not found after bootstrap: invoke `"<skill-root>/bin/browser-cli"` on macOS arm64 or `& "<skill-root>\bin\browser-cli.exe"` in Windows PowerShell; no PATH change or restart is required.

## Windows diagnostic output

For an installed CLI, use its absolute `bin/browser-cli.exe` path with `doctor`.
The `doctor.ps1` helper is not required. Under PowerShell's `Restricted` execution
policy, a `.ps1` file can be rejected before its body or the CLI runs; this is not
evidence of a CLI authentication or cloud-browser failure. Do not lower execution
policy, disable the sandbox, or reinstall a working binary to run this check. If
the binary is missing and the bootstrap script is blocked, report the installation
prerequisite instead of bypassing the policy.

WorkBuddy's Windows PowerShell tool may report `Command completed with exit code
0` (or `1`) without stdout/stderr. Do not treat that as the doctor's JSON or infer
a specific failure cause from the code alone. If the harness already provides
Bash/Git Bash, invoke the same Windows executable there; do not install another
shell or use a different-platform binary. For the read-only `version`/`doctor`
checks, retrying once to recover missing output is sufficient. Read the actual
JSON and preserve any reported failure; do not keep retrying to obtain success.

If Bash is unavailable, capture the read-only command's output in a new task-local
file and read it with the harness's file-reading tool. If that also fails, report
the output-capture limitation rather than claiming readiness or reauthorizing.
This fallback is not permission to repeat state-changing commands such as creating
sessions: inspect their existing result/state before considering any retry. Do not
include credentials, tokens or authentication configuration in diagnostic files.

Always close a newly created temporary session when abandoning a failed task.
2 changes: 1 addition & 1 deletion skills/lexmount-browser/scripts/bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function Invoke-Tls12Download {
}
}

$version = if ($env:LEXMOUNT_BROWSER_CLI_VERSION) { $env:LEXMOUNT_BROWSER_CLI_VERSION } else { "1.2.3" }
$version = if ($env:LEXMOUNT_BROWSER_CLI_VERSION) { $env:LEXMOUNT_BROWSER_CLI_VERSION } else { "1.2.4" }
$downloadBaseUrl = if ($env:LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL) { $env:LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL.TrimEnd('/') } else { "https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/browser-cli" }
$architecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
if ($architecture -ne "AMD64") { throw "Only Windows x64 is supported" }
Expand Down
2 changes: 1 addition & 1 deletion skills/lexmount-browser/scripts/bootstrap.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu

version="${LEXMOUNT_BROWSER_CLI_VERSION:-1.2.3}"
version="${LEXMOUNT_BROWSER_CLI_VERSION:-1.2.4}"
download_base_url="${LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL:-https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/browser-cli}"
repo="${download_base_url%/}/v${version}"
case "$(uname -s)-$(uname -m)" in
Expand Down
5 changes: 1 addition & 4 deletions src/cdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use tungstenite::Message;

use crate::{Error, Result};

mod click;
mod proxy;

pub struct Cdp {
Expand Down Expand Up @@ -162,10 +163,6 @@ impl Cdp {
.unwrap_or(Value::Null))
}

pub fn click(&mut self, selector: &str) -> Result<Value> {
self.evaluate(&format!("(()=>{{const e=document.querySelector({});if(!e)throw new Error('selector not found');e.scrollIntoView({{block:'center'}});e.click();return true}})()", serde_json::to_string(selector)?))
}

pub fn fill(&mut self, selector: &str, value: &str) -> Result<Value> {
self.evaluate(&format!("(()=>{{const e=document.querySelector({});if(!e)throw new Error('selector not found');const s=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),'value')?.set;s?s.call(e,{}):e.value={};e.dispatchEvent(new Event('input',{{bubbles:true}}));e.dispatchEvent(new Event('change',{{bubbles:true}}));return true}})()", serde_json::to_string(selector)?, serde_json::to_string(value)?, serde_json::to_string(value)?))
}
Expand Down
136 changes: 136 additions & 0 deletions src/cdp/click.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use serde_json::{Value, json};

use super::{Cdp, javascript_exception_message};
use crate::{Error, Result};

// Keep the original DOM object across the hover check. Re-querying the selector
// could silently click a replacement element installed by a mousemove handler.
const CLICK_POINT: &str = r#"function(point) {
const e = this;
if (!e.isConnected || e.ownerDocument !== document)
throw new Error('click target is detached');
if (e.matches(':disabled') || e.closest(
'button:disabled, input:disabled, select:disabled, textarea:disabled, option:disabled, optgroup:disabled, [inert], [aria-disabled="true" i]'))
throw new Error('click target is disabled or inert');
const style = getComputedStyle(e);
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse')
throw new Error('click target is not visible');
if (!point) e.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
const width = document.documentElement.clientWidth;
const height = document.documentElement.clientHeight;
const rects = [...e.getClientRects()].map(r => ({
left: Math.max(0, r.left), right: Math.min(width, r.right),
top: Math.max(0, r.top), bottom: Math.min(height, r.bottom)
})).filter(r => r.right > r.left && r.bottom > r.top);
if (!rects.length) throw new Error('click target has no visible area in the viewport');
if (point && !rects.some(r => point.x >= r.left && point.x < r.right &&
point.y >= r.top && point.y < r.bottom))
throw new Error('click target moved after hover; inspect the page and try again');
const candidates = point ? [point] : rects.map(r => ({
x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2
}));
for (const candidate of candidates) {
const hit = document.elementFromPoint(candidate.x, candidate.y);
if (hit && (hit === e || e.contains(hit))) return candidate;
}
throw new Error('click target is covered or cannot receive pointer events');
}"#;

#[derive(Clone, Copy, PartialEq)]
struct Point {
x: f64,
y: f64,
}

impl Cdp {
/// Click a main-document CSS selector using native CDP mouse input.
///
/// Success means the input was dispatched, not that the site's task or
/// navigation succeeded. No JavaScript-click fallback or tab switching occurs.
pub fn click(&mut self, selector: &str) -> Result<Value> {
let response = self.command(
"Runtime.evaluate",
json!({
"expression": format!(
"(()=>{{const e=document.querySelector({});if(!e)throw new Error('selector not found');return e}})()",
serde_json::to_string(selector)?
),
"returnByValue": false
}),
)?;
let remote = runtime_result(&response)?;
let object_id = remote["objectId"]
.as_str()
.filter(|id| !id.is_empty())
.ok_or_else(|| Error::Cdp("click target response missing objectId".into()))?;

let result = self.click_object(object_id);
// Navigation may already have destroyed the context. Cleanup must not
// turn a dispatched click into failure or hide its original error.
let _ = self.command("Runtime.releaseObject", json!({"objectId": object_id}));
result
}

fn click_object(&mut self, object_id: &str) -> Result<Value> {
let point = self.click_point(object_id, None)?;
self.mouse_event("mouseMoved", point, "none", 0, 0)?;
// Hover can move, cover, disable or replace the target. Never chase a
// changed selector or blindly press at its stale coordinates.
if self.click_point(object_id, Some(point))? != point {
return Err(Error::Cdp("click target changed after hover".into()));
}
let pressed = self.mouse_event("mousePressed", point, "left", 1, 1);
// Even if pressing returns an error, best-effort release avoids leaving
// the button down after a partially handled command. Never repeat press.
let released = self.mouse_event("mouseReleased", point, "left", 0, 1);
pressed?;
released?;
Ok(json!(true))
}

fn click_point(&mut self, object_id: &str, point: Option<Point>) -> Result<Point> {
let response = self.command(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": CLICK_POINT,
"arguments": [{"value": point.map(|p| json!({"x":p.x,"y":p.y}))}],
"returnByValue": true
}),
)?;
let value = &runtime_result(&response)?["value"];
let coordinate = |name| {
value[name]
.as_f64()
.filter(|v| v.is_finite() && *v >= 0.0)
.ok_or_else(|| Error::Cdp("click target response has invalid coordinates".into()))
};
Ok(Point {
x: coordinate("x")?,
y: coordinate("y")?,
})
}

fn mouse_event(
&mut self,
event: &str,
point: Point,
button: &str,
buttons: u8,
count: u8,
) -> Result<Value> {
self.command(
"Input.dispatchMouseEvent",
json!({"type":event, "x":point.x, "y":point.y,
"button":button, "buttons":buttons, "clickCount":count,
"pointerType":"mouse", "modifiers":0}),
)
}
}

fn runtime_result(response: &Value) -> Result<&Value> {
if let Some(exception) = response.get("exceptionDetails") {
return Err(Error::Cdp(javascript_exception_message(exception)));
}
Ok(&response["result"])
}
Loading
Loading