Skip to content
Open
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: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ cargo build --release
Configuration is loaded from `LEXMOUNT_API_KEY`, `LEXMOUNT_PROJECT_ID`, optional
`LEXMOUNT_BASE_URL` (default `https://api.lexmount.cn`), and optional
`LEXMOUNT_REGION`. `browser-cli auth login` uses a loopback callback and PKCE;
credentials are stored at `~/.config/lexmount/browser-cli/credentials.json` with
mode `0600` on Unix and are never printed.
pass `--client-name "<name>"` to identify the calling Agent in the approval page,
or omit it to use `Agent`. Credentials are stored at
`~/.config/lexmount/browser-cli/credentials.json` with mode `0600` on Unix and
are never printed.

All commands emit one JSON document. Run `browser-cli --help` for the complete
surface.

## WorkBuddy package
## Agent Skill package

The publishable Skill is in `skills/lexmount-browser`. Build a deterministic ZIP:

Expand All @@ -35,6 +37,13 @@ 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.

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
`${CODEBUDDY_SKILL_DIR}`. These are host-level Skill locators, not installation
or download inputs. Once started, the bootstrap and doctor scripts also locate
the Skill directory from their own path.

Published binaries are intentionally limited to two targets: macOS arm64 and
Windows x64. The macOS binary is signed with a Developer ID Application
certificate, hardened-runtime enabled, and accepted by Apple's notarization
Expand Down
24 changes: 18 additions & 6 deletions skills/lexmount-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,32 @@ description: Use Lexmount cloud browsers to open and interact with JavaScript-he

# Lexmount Browser

Resolve `<skill-root>` to the directory containing this loaded `SKILL.md` with
the current Agent's Skill locator:

- Codex: use the absolute `SKILL.md` source path supplied in the Skill metadata.
- Claude Code: use `${CLAUDE_SKILL_DIR}`.
- WorkBuddy/CodeBuddy: use `${CODEBUDDY_SKILL_DIR}`.

Do not infer `<skill-root>` from the working directory.

Select the native Rust binary for the current platform:

- macOS arm64: run `${CODEBUDDY_SKILL_DIR}/scripts/bootstrap.sh` when `${CODEBUDDY_SKILL_DIR}/bin/browser-cli` is missing, then use that file.
- Windows x64: run `${CODEBUDDY_SKILL_DIR}/scripts/bootstrap.ps1` when `${CODEBUDDY_SKILL_DIR}/bin/browser-cli.exe` is missing, then use that file.
- macOS arm64: run `sh "<skill-root>/scripts/bootstrap.sh"` when `<skill-root>/bin/browser-cli` is missing, then invoke `"<skill-root>/bin/browser-cli"`.
- Windows x64: run `& "<skill-root>\scripts\bootstrap.ps1"` in PowerShell when `<skill-root>\bin\browser-cli.exe` is missing, then invoke `& "<skill-root>\bin\browser-cli.exe"`.

Both bootstrap scripts download the fixed release version from Tencent Cloud COS and verify its SHA-256 digest.
The Agent-specific locator is needed to form the initial absolute command. Once
started, the bootstrap and doctor scripts locate the Skill directory from their
own file location.

Do not run the binary for the other platform. Both platform binaries emit JSON. The examples below abbreviate the selected path as `browser-cli`; resolve it before running commands.
Do not run the binary for the other platform. Both platform binaries emit JSON. The examples below abbreviate the selected absolute path as `browser-cli`; resolve it before running commands and do not assume it is on `PATH`.

## Setup

1. On macOS arm64, run `bootstrap.sh` if `bin/browser-cli` is missing, then run `doctor.sh`.
2. On Windows x64, run `bootstrap.ps1` if `bin/browser-cli.exe` is missing, then run `doctor.ps1`.
3. If credentials are missing, run `browser-cli auth login`. Let the user approve in their browser. Never ask them to paste an API key into chat.
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.
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.

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
8 changes: 6 additions & 2 deletions skills/lexmount-browser/references/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
Preferred login:

```text
browser-cli auth login
browser-cli auth login [--client-name "NAME"]
```

The default client name is `Agent`. Quote and pass the current Agent's
user-facing name when available; for example, WorkBuddy can pass
`--client-name "WorkBuddy"`.

The CLI binds a random loopback port on `127.0.0.1`, creates a PKCE verifier and state, opens the Lexmount approval page, exchanges the returned one-time code, and stores the scoped credential in:

```text
Expand All @@ -14,6 +18,6 @@ The CLI binds a random loopback port on `127.0.0.1`, creates a PKCE verifier and

The file is mode `0600` on Unix. The CLI redacts the API key from all JSON output.

For managed environments, the SDK also accepts `LEXMOUNT_API_KEY`, `LEXMOUNT_PROJECT_ID`, optional `LEXMOUNT_BASE_URL`, and optional `LEXMOUNT_REGION`. Do not ask users to paste secret values into WorkBuddy chat.
For managed environments, the SDK also accepts `LEXMOUNT_API_KEY`, `LEXMOUNT_PROJECT_ID`, optional `LEXMOUNT_BASE_URL`, and optional `LEXMOUNT_REGION`. Do not ask users to paste secret values into an Agent chat.

Use `browser-cli auth logout` to remove only the local credential file. Environment variables are managed outside the CLI.
4 changes: 3 additions & 1 deletion skills/lexmount-browser/references/commands.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Command reference

Every command returns a JSON object with `ok` and either `data` or `error`.
The examples use `browser-cli` as shorthand for the Skill-local binary resolved
from the directory containing `SKILL.md`; invoke that binary by its absolute path.

```text
browser-cli doctor
browser-cli auth status
browser-cli auth login
browser-cli auth login [--client-name "NAME"]

browser-cli session create [--browser-mode normal|light]
[--context-id ID --context-mode read_write|read_only]
Expand Down
3 changes: 2 additions & 1 deletion skills/lexmount-browser/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Run `browser-cli doctor` first and use the failed check's message.
- `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.
- command not found after bootstrap: use the Skill-local binary under `${CODEBUDDY_SKILL_DIR}/bin/`; no PATH change or restart is required.
- 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.

Always close a newly created temporary session when abandoning a failed task.
144 changes: 114 additions & 30 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ use url::Url;
use crate::{Error, Result};

pub const DEFAULT_CONNECT_BASE_URL: &str = "https://browser.lexmount.cn";
pub const DEFAULT_CLIENT_NAME: &str = "Agent";
pub const DEFAULT_SCOPES: &[&str] = &["browser:sessions", "browser:contexts", "browser:actions"];
const LOGIN_SUCCESS_PAGE: &str = "<!doctype html><meta charset=utf-8><title>Lexmount connected</title><h1>Lexmount connected</h1><p>You can close this window and return to your agent.</p>";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
Expand Down Expand Up @@ -143,6 +145,24 @@ pub fn login(
timeout: Duration,
open_browser: bool,
path: Option<&Path>,
) -> Result<Value> {
login_with_client_name(
project_id,
DEFAULT_CLIENT_NAME,
connect_base_url,
timeout,
open_browser,
path,
)
}

pub fn login_with_client_name(
project_id: Option<&str>,
client_name: &str,
connect_base_url: &str,
timeout: Duration,
open_browser: bool,
path: Option<&Path>,
) -> Result<Value> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
Expand All @@ -153,32 +173,14 @@ pub fn login(
let verifier = random_urlsafe(32);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let state = random_urlsafe(24);
let scopes = DEFAULT_SCOPES
.iter()
.map(|v| (*v).to_owned())
.collect::<Vec<_>>();

let mut url = Url::parse(&format!(
"{}/connect/codex",
connect_base_url.trim_end_matches('/')
))
.map_err(|e| Error::Config(format!("invalid connect base URL: {e}")))?;
{
let mut q = url.query_pairs_mut();
q.append_pair("source", "browser-cli")
.append_pair("intent", "agent-browser-control")
.append_pair("response", "code")
.append_pair("expires_in", "7d")
.append_pair("scope", &scopes.join(" "))
.append_pair("redirect_uri", &redirect_uri)
.append_pair("state", &state)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("client_name", "WorkBuddy");
if let Some(project_id) = project_id {
q.append_pair("project_id", project_id);
}
}
let url = authorization_url(
project_id,
client_name,
connect_base_url,
&redirect_uri,
&state,
&challenge,
)?;
if open_browser {
open::that(url.as_str()).map_err(|e| Error::Io(std::io::Error::other(e)))?;
}
Expand All @@ -198,13 +200,12 @@ pub fn login(
.ok_or_else(|| Error::Config("invalid OAuth callback request".into()))?;
let callback = Url::parse(&format!("http://127.0.0.1{target}"))
.map_err(|e| Error::Config(format!("invalid OAuth callback: {e}")))?;
let body = b"<!doctype html><meta charset=utf-8><title>Lexmount connected</title><h1>Lexmount connected</h1><p>You can return to WorkBuddy.</p>";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
LOGIN_SUCCESS_PAGE.len()
)?;
stream.write_all(body)?;
stream.write_all(LOGIN_SUCCESS_PAGE.as_bytes())?;
break callback;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && started.elapsed() < timeout => {
Expand Down Expand Up @@ -272,6 +273,39 @@ pub fn login(
)
}

fn authorization_url(
project_id: Option<&str>,
client_name: &str,
connect_base_url: &str,
redirect_uri: &str,
state: &str,
challenge: &str,
) -> Result<Url> {
let scopes = DEFAULT_SCOPES.join(" ");
let mut url = Url::parse(&format!(
"{}/connect/codex",
connect_base_url.trim_end_matches('/')
))
.map_err(|e| Error::Config(format!("invalid connect base URL: {e}")))?;
{
let mut q = url.query_pairs_mut();
q.append_pair("source", "browser-cli")
.append_pair("intent", "agent-browser-control")
.append_pair("response", "code")
.append_pair("expires_in", "7d")
.append_pair("scope", &scopes)
.append_pair("redirect_uri", redirect_uri)
.append_pair("state", state)
.append_pair("code_challenge", challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("client_name", client_name);
if let Some(project_id) = project_id {
q.append_pair("project_id", project_id);
}
}
Ok(url)
}

fn random_urlsafe(size: usize) -> String {
let mut bytes = vec![0_u8; size];
rand::rng().fill_bytes(&mut bytes);
Expand Down Expand Up @@ -330,7 +364,7 @@ fn extract_api_key(payload: &Value) -> Option<Credentials> {
.or_else(|| value.get("expiresAt"))
.and_then(Value::as_str)
.map(str::to_owned),
source: Some("connect_from_workbuddy".into()),
source: Some("connect_from_browser_cli".into()),
connect_base_url: Some(DEFAULT_CONNECT_BASE_URL.into()),
created_at: None,
});
Expand All @@ -352,12 +386,62 @@ fn is_internal_api_base_url(value: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extracts_nested_credentials_without_leaking_secret() {
let c = extract_api_key(&json!({"credential":{"projectId":"p1","apiKey":"secret","apiBaseUrl":"https://api.example","scope":"browser:sessions browser:actions"}})).unwrap();
assert_eq!(c.project_id, "p1");
assert_eq!(c.api_key, "secret");
assert_eq!(c.scopes.len(), 2);
assert_eq!(c.source.as_deref(), Some("connect_from_browser_cli"));
}

#[test]
fn authorization_url_encodes_custom_client_name() {
let client_name = "Claude Desktop 中文";
let url = authorization_url(
Some("project-1"),
client_name,
"https://browser.example/",
"http://127.0.0.1:1234/callback",
"state",
"challenge",
)
.unwrap();

assert!(
url.as_str()
.contains("client_name=Claude+Desktop+%E4%B8%AD%E6%96%87")
);
assert_eq!(
url.query_pairs()
.find(|(key, _)| key.as_ref() == "client_name")
.map(|(_, value)| value.into_owned())
.as_deref(),
Some(client_name)
);
}

#[test]
fn login_success_page_is_agent_agnostic() {
assert!(
!LOGIN_SUCCESS_PAGE
.to_ascii_lowercase()
.contains("workbuddy")
);
assert!(LOGIN_SUCCESS_PAGE.contains("return to your agent"));
}

#[test]
fn legacy_login_api_signature_is_preserved() {
type LegacyLogin = for<'a, 'b, 'c> fn(
Option<&'a str>,
&'b str,
Duration,
bool,
Option<&'c Path>,
) -> Result<Value>;
let _: LegacyLogin = login;
}

#[test]
Expand Down
44 changes: 43 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ enum AuthCommand {
Login {
#[arg(long)]
project_id: Option<String>,
#[arg(long, default_value = auth::DEFAULT_CLIENT_NAME)]
client_name: String,
#[arg(long, default_value = auth::DEFAULT_CONNECT_BASE_URL)]
connect_base_url: String,
#[arg(long, default_value_t = 300)]
Expand Down Expand Up @@ -313,12 +315,14 @@ fn run_auth(command: AuthCommand) -> Result<Value> {
AuthCommand::Status => Ok(serde_json::to_value(auth::status(None)?)?),
AuthCommand::Login {
project_id,
client_name,
connect_base_url,
timeout_seconds,
no_open,
credentials_file,
} => auth::login(
} => auth::login_with_client_name(
project_id.as_deref(),
&client_name,
&connect_base_url,
Duration::from_secs(timeout_seconds),
!no_open,
Expand Down Expand Up @@ -567,3 +571,41 @@ fn error_kind(error: &Error) -> &'static str {
fn print_json<T: Serialize>(value: &T) {
println!("{}", serde_json::to_string(value).unwrap());
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn auth_login_defaults_client_name_to_agent() {
let cli = Cli::try_parse_from(["browser-cli", "auth", "login"]).unwrap();
let Command::Auth {
command: AuthCommand::Login { client_name, .. },
} = cli.command
else {
panic!("expected auth login command");
};

assert_eq!(client_name, auth::DEFAULT_CLIENT_NAME);
}

#[test]
fn auth_login_parses_custom_client_name() {
let client_name = "Claude Desktop 中文";
let cli =
Cli::try_parse_from(["browser-cli", "auth", "login", "--client-name", client_name])
.unwrap();
let Command::Auth {
command:
AuthCommand::Login {
client_name: parsed,
..
},
} = cli.command
else {
panic!("expected auth login command");
};

assert_eq!(parsed, client_name);
}
}