diff --git a/README.md b/README.md index cb25f5a..b800eb1 100644 --- a/README.md +++ b/README.md @@ -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 ""` 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: @@ -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 diff --git a/skills/lexmount-browser/SKILL.md b/skills/lexmount-browser/SKILL.md index 5197168..aa9e797 100644 --- a/skills/lexmount-browser/SKILL.md +++ b/skills/lexmount-browser/SKILL.md @@ -5,20 +5,32 @@ description: Use Lexmount cloud browsers to open and interact with JavaScript-he # Lexmount Browser +Resolve `` 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 `` 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 "/scripts/bootstrap.sh"` when `/bin/browser-cli` is missing, then invoke `"/bin/browser-cli"`. +- Windows x64: run `& "\scripts\bootstrap.ps1"` in PowerShell when `\bin\browser-cli.exe` is missing, then invoke `& "\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 `` 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 "/scripts/doctor.sh"` on macOS arm64 or `& "\scripts\doctor.ps1"` in Windows PowerShell. +3. If credentials are missing, run `browser-cli auth login`. Pass `--client-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. diff --git a/skills/lexmount-browser/references/authentication.md b/skills/lexmount-browser/references/authentication.md index 2441df1..6fb346a 100644 --- a/skills/lexmount-browser/references/authentication.md +++ b/skills/lexmount-browser/references/authentication.md @@ -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 @@ -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. diff --git a/skills/lexmount-browser/references/commands.md b/skills/lexmount-browser/references/commands.md index 3694c3b..8cdf799 100644 --- a/skills/lexmount-browser/references/commands.md +++ b/skills/lexmount-browser/references/commands.md @@ -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] diff --git a/skills/lexmount-browser/references/troubleshooting.md b/skills/lexmount-browser/references/troubleshooting.md index 20a5496..0c6921b 100644 --- a/skills/lexmount-browser/references/troubleshooting.md +++ b/skills/lexmount-browser/references/troubleshooting.md @@ -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 `"/bin/browser-cli"` on macOS arm64 or `& "\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. diff --git a/src/auth.rs b/src/auth.rs index 3f82316..51c1186 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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 = "Lexmount connected

Lexmount connected

You can close this window and return to your agent.

"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Credentials { @@ -143,6 +145,24 @@ pub fn login( timeout: Duration, open_browser: bool, path: Option<&Path>, +) -> Result { + 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 { let listener = TcpListener::bind("127.0.0.1:0")?; listener.set_nonblocking(true)?; @@ -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::>(); - - 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)))?; } @@ -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"Lexmount connected

Lexmount connected

You can return to WorkBuddy.

"; 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 => { @@ -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 { + 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); @@ -330,7 +364,7 @@ fn extract_api_key(payload: &Value) -> Option { .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, }); @@ -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; + let _: LegacyLogin = login; } #[test] diff --git a/src/main.rs b/src/main.rs index fdec029..ceaafa7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,8 @@ enum AuthCommand { Login { #[arg(long)] project_id: Option, + #[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)] @@ -313,12 +315,14 @@ fn run_auth(command: AuthCommand) -> Result { 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, @@ -567,3 +571,41 @@ fn error_kind(error: &Error) -> &'static str { fn print_json(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); + } +}