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
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.1.15"
version = "1.2.0"
edition = "2024"
license = "MIT"
description = "Native Rust SDK and CLI for Lexmount cloud browsers"
Expand Down
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,65 @@ are never printed.
All commands emit one JSON document. Run `browser-cli --help` for the complete
surface.

## Select a page in a multi-tab session

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.0 together. Merging or building this source does not publish release assets:
bootstrap can install 1.2.0 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
target ID from `session targets` (the page entry's `id` in a DevTools `/json`
listing), then pass it on **each** action that should use that tab:

```bash
browser-cli session targets --session-id SESSION_ID
browser-cli action snapshot --session-id SESSION_ID --target-id PAGE_ID
browser-cli action fill --session-id SESSION_ID --target-id PAGE_ID --selector '#query' --value 'search terms'
browser-cli action click --session-id SESSION_ID --target-id PAGE_ID --selector '#search'
# If this opened a new tab, list targets again and select the result page.
browser-cli session targets --session-id SESSION_ID
browser-cli action wait-selector --session-id SESSION_ID --target-id RESULT_PAGE_ID --selector '#results'
browser-cli action snapshot --session-id SESSION_ID --target-id RESULT_PAGE_ID
```

The option can also precede the action subcommand:
`browser-cli action --target-id PAGE_ID snapshot --session-id SESSION_ID`.
It applies to all actions, including `open-url`, `screenshot`, `pdf`, and `raw`;
their JSON result shapes are unchanged. It is not a session/context option.

The browser session ID and page target ID identify different things. An explicit
target must be an existing page in that browser session. A missing/closed target
returns `not_found`; a non-page target returns `configuration_error`. If the page
closes between discovery and attachment, the CDP error is propagated. None of
these cases falls back to another tab or creates a blank page.

Without `--target-id`, the existing default is unchanged: select the first page
returned by CDP, or create `about:blank` if no page exists. That default is **not**
a guarantee to follow a popup or select the most recently used tab. Explicit
selection is per invocation; there is no persisted active-page state or automatic
new-tab switching. Select by the task's expected URL/title, not list position,
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.

### Local regression tests

```bash
cargo test --all-targets --locked
# Optional: use a local Chrome/Chromium executable, including chrome-headless-shell.
BROWSER_CLI_TEST_CHROME=/path/to/chrome cargo test --locked --test page_targets_browser -- --ignored --nocapture
```

In PowerShell, set `$env:BROWSER_CLI_TEST_CHROME` to the executable path before
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.

## Agent Skill package

The publishable Skill is in `skills/lexmount-browser`. Build a deterministic ZIP:
Expand All @@ -37,6 +96,14 @@ release from Tencent Cloud COS and verifies its SHA-256 digest. Set
`LEXMOUNT_BROWSER_CLI_VERSION` or `LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL` only
when testing a different published release or mirror.

Updating the Skill files does not replace an existing Skill-local executable.
After the pinned release is available, an authorized upgrade can rerun the
matching bundled bootstrap script, then verify `browser-cli version` and
`browser-cli action --help`. If the release is not available, report that
dependency rather than substituting an older binary for a task needing the new
feature. Release tags must match the Cargo and bootstrap versions; never
overwrite an existing release with changed binaries.

Agents resolve bundled scripts and binaries from the directory containing the
loaded `SKILL.md`: Codex uses the absolute source path supplied in the Skill
metadata, Claude Code uses `${CLAUDE_SKILL_DIR}`, and WorkBuddy/CodeBuddy uses
Expand Down
12 changes: 12 additions & 0 deletions skills/lexmount-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ Read [authentication.md](references/authentication.md) only when login or creden
5. Take screenshots when visual confirmation matters.
6. Close temporary sessions with `browser-cli session close`. A read-write Context saves state on normal session close.

For multi-tab work, first check that `browser-cli action --help` lists
`--target-id`, introduced in 1.2.0. Updating the Skill does not upgrade an existing
binary. If absent, follow the [upgrade guidance](references/commands.md#page-selection)
and report the limitation if an upgrade cannot be completed; default-page actions
are not an equivalent substitute.
With support available, inspect `session targets`, select the page matching the task,
and pass its ID as `--target-id` on each action. After a click opens a new tab,
list targets again and explicitly select that page before waiting or inspecting;
an unchanged source page alone does not mean the click failed. See
[page selection](references/commands.md#page-selection) for discovery, compatibility,
and missing-target handling. Do not infer the active page from list order.

## Safety

- Ask before submitting purchases, publishing content, deleting remote data, or changing account/security settings.
Expand Down
42 changes: 42 additions & 0 deletions skills/lexmount-browser/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,48 @@ browser-cli action eval --session-id ID --expression JS
browser-cli action raw --session-id ID --method CDP_METHOD --params-json JSON
```

## Page selection

Introduced in 1.2.0; requires a binary whose `browser-cli action --help` lists
`--target-id`. The published 1.1.15 binary does not include this feature.
Check the actual Skill-local binary, not just the version of these instructions.

For an authorized upgrade, rerun the matching Skill-local bootstrap script only
after its pinned 1.2.0 release assets are available, then verify `version` and
`action --help`. A merged PR or a newer Skill file does not publish or replace
the binary. If the release is unavailable or the upgrade is not authorized,
report the dependency or capability limitation; do not send unsupported flags
or drop the target selection to continue against a different page.

Every `action` above accepts optional `--target-id PAGE_ID`, before or after the
action subcommand. It selects an existing page inside `--session-id`; it is not
a replacement for the browser session ID. JSON output shapes are unchanged.

```text
browser-cli session targets --session-id SESSION_ID
browser-cli action snapshot --session-id SESSION_ID --target-id PAGE_ID
browser-cli action click --session-id SESSION_ID --target-id PAGE_ID --selector CSS
browser-cli session targets --session-id SESSION_ID
browser-cli action wait-selector --session-id SESSION_ID --target-id NEW_PAGE_ID --selector CSS
browser-cli action snapshot --session-id SESSION_ID --target-id NEW_PAGE_ID
```

Use the page entry's `id` in the DevTools target listing (`targetId` when using
CDP `Target.getTargets` directly). Match the expected URL/title and page type,
not the first/last position or an attached CDP `sessionId`. A popup may take time
to appear or navigate: refresh the listing within a bounded task timeout, then
wait for the required selector/text on the selected page. If several pages are
plausible, inspect them before choosing; do not blindly retry a state-changing
click. Pass the chosen ID on each subsequent action; selection is not persisted.

Without this option, the CLI keeps its original default: attach to the first
page returned by CDP, or create `about:blank` if no page exists. It does not
automatically follow a newly opened tab. A missing/closed explicit target fails
with `not_found`; a non-page target fails with `configuration_error`. A target
closed during attachment can produce `cdp_error`. The CLI never falls back to
another page or creates a page when an explicit target cannot be used. Re-list
targets and reassess the task instead of dropping `--target-id` to bypass errors.

Use temporary sessions for public browsing. Use a dedicated persistent Context per account or purpose; avoid sharing one read-write Context between parallel tasks.

`wait-text` uses case-insensitive normalized contains matching by default. Add
Expand Down
6 changes: 4 additions & 2 deletions skills/lexmount-browser/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

Run `browser-cli doctor` first and use the failed check's message.

- `configuration_error`: run `browser-cli auth login`, or verify the managed environment contains both required variables.
- `configuration_error`: read the message first. For an empty or non-page `--target-id`, inspect `session targets` and select a page; re-authentication will not fix page selection. For missing credentials, run `browser-cli auth login`, or verify the managed environment contains both required variables.
- `not_found` for a page target: it may have closed or belong to another browser session. Re-list `session targets` for the intended session and choose the correct page. Do not remove `--target-id` and accidentally retry against the default page.
- `authentication_error`: the credential was rejected; log out and authorize again.
- `conflict`: a read-write Context is already locked. Use another Context, wait for the active session, or use read-only mode. Force-release only after confirming the session is dead.
- `timeout`: inspect session status and network access, then retry with a larger timeout.
- `cdp_error`: verify the session is active, inspect `session targets`, and take a snapshot before retrying the action.
- `cdp_error`: verify the session is active and inspect `session targets`; a page can disappear between listing and attachment. Take a snapshot of the explicitly selected page before deciding whether to retry the action.
- Source URL unchanged after a click: the click may have opened a new tab. Inspect `session targets` and use `--target-id` for the intended new page; do not assume the click failed or that subsequent commands automatically follow it. See [page selection](commands.md#page-selection).
- 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.

Expand Down
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.1.15" }
$version = if ($env:LEXMOUNT_BROWSER_CLI_VERSION) { $env:LEXMOUNT_BROWSER_CLI_VERSION } else { "1.2.0" }
$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.1.15}"
version="${LEXMOUNT_BROWSER_CLI_VERSION:-1.2.0}"
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
132 changes: 120 additions & 12 deletions src/cdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,26 @@ pub struct WaitTextOptions<'a> {
}

impl Cdp {
/// Connect to the first page, creating a blank page if necessary.
///
/// This preserves the original single-page default. Use `connect_to_target`
/// to address a particular tab without depending on target enumeration order.
pub fn connect(url: &str) -> Result<Self> {
Self::connect_with_target(url, None)
}

/// Connect to an existing page in this browser session.
///
/// Missing, closed, or non-page targets return an error; they never fall back
/// to another page or cause a blank page to be created.
pub fn connect_to_target(url: &str, target_id: &str) -> Result<Self> {
if target_id.trim().is_empty() {
return Err(Error::Config("target ID must not be empty".into()));
}
Self::connect_with_target(url, Some(target_id))
}

fn connect_with_target(url: &str, requested_target: Option<&str>) -> Result<Self> {
let (socket, _) = tungstenite::connect(url)?;
let mut client = Self {
socket,
Expand All @@ -40,17 +59,7 @@ impl Cdp {
events: VecDeque::new(),
};
let targets = client.command_root("Target.getTargets", json!({}))?;
let target_id = targets
.get("targetInfos")
.and_then(Value::as_array)
.and_then(|items| {
items
.iter()
.find(|v| v.get("type").and_then(Value::as_str) == Some("page"))
})
.and_then(|v| v.get("targetId"))
.and_then(Value::as_str)
.map(str::to_owned);
let target_id = select_page_target(&targets, requested_target)?;
let target_id = match target_id {
Some(id) => id,
None => client
Expand Down Expand Up @@ -280,6 +289,31 @@ impl Cdp {
}
}

fn select_page_target(targets: &Value, requested: Option<&str>) -> Result<Option<String>> {
let pages = targets.get("targetInfos").and_then(Value::as_array);
if let Some(id) = requested {
let pages = pages
.ok_or_else(|| Error::Cdp("Target.getTargets response missing targetInfos".into()))?;
let target = pages
.iter()
.find(|item| item["targetId"].as_str() == Some(id));
match target {
Some(item) if item["type"] == "page" => Ok(Some(id.to_owned())),
Some(_) => Err(Error::Config(format!(
"target {id} is not a page; inspect `session targets` and select a page target"
))),
None => Err(Error::NotFound(format!(
"page target {id} is not available in this session; inspect `session targets`"
))),
}
} else {
Ok(pages
.and_then(|items| items.iter().find(|item| item["type"] == "page"))
.and_then(|item| item["targetId"].as_str())
.map(str::to_owned))
}
}

fn text_matches(candidate: &str, query: &str, exact: bool, case_sensitive: bool) -> bool {
let normalize = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" ");
let mut haystack = normalize(candidate);
Expand All @@ -297,7 +331,81 @@ fn text_matches(candidate: &str, query: &str, exact: bool, case_sensitive: bool)

#[cfg(test)]
mod tests {
use super::text_matches;
use super::{select_page_target, text_matches};
use crate::Error;
use serde_json::json;

#[test]
fn explicit_page_selection_does_not_depend_on_order() {
let home = json!({"type":"page","targetId":"home"});
let result = json!({"type":"page","targetId":"result"});
for items in [vec![home.clone(), result.clone()], vec![result, home]] {
let targets = json!({"targetInfos":items});
assert_eq!(
select_page_target(&targets, Some("result")).unwrap(),
Some("result".into())
);
}
}

#[test]
fn missing_or_non_page_target_never_falls_back() {
let targets = json!({"targetInfos":[
{"type":"service_worker","targetId":"worker"},
{"type":"page","targetId":"home"}
]});
assert!(matches!(
select_page_target(&targets, Some("closed")),
Err(Error::NotFound(_))
));
assert!(matches!(
select_page_target(&targets, Some("worker")),
Err(Error::Config(_))
));
assert!(matches!(
select_page_target(&json!({"targetInfos":[]}), Some("missing")),
Err(Error::NotFound(_))
));
}

#[test]
fn unspecified_target_preserves_first_page_and_empty_browser_defaults() {
let targets = json!({"targetInfos":[
{"type":"service_worker","targetId":"worker"},
{"type":"page","targetId":"home"},
{"type":"page","targetId":"result"}
]});
assert_eq!(
select_page_target(&targets, None).unwrap(),
Some("home".into())
);
assert_eq!(
select_page_target(&json!({"targetInfos":[]}), None).unwrap(),
None
);
}

#[test]
fn malformed_explicit_target_listing_is_rejected() {
assert!(matches!(
select_page_target(&json!({}), Some("page")),
Err(Error::Cdp(_))
));
assert!(matches!(
select_page_target(&json!({"targetInfos":null}), Some("page")),
Err(Error::Cdp(_))
));
}

#[test]
fn empty_explicit_target_is_rejected_before_connecting() {
for id in ["", " "] {
assert!(matches!(
super::Cdp::connect_to_target("not-a-websocket", id),
Err(Error::Config(_))
));
}
}

#[test]
fn wait_text_defaults_to_case_insensitive_contains() {
Expand Down
Loading