Skip to content

Commit f0ad71b

Browse files
authored
Support Browser Skill across multiple agents (#21)
* feat: support multiple agent clients * docs(skill): document agent-specific skill locators * chore: bump browser-cli to 1.1.12 --------- Co-authored-by: mayuqin746 <233007943+mayuqin746@users.noreply.github.com>
1 parent 1c8e949 commit f0ad71b

11 files changed

Lines changed: 202 additions & 48 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lexmount-browser"
3-
version = "1.1.11"
3+
version = "1.1.12"
44
edition = "2024"
55
license = "MIT"
66
description = "Native Rust SDK and CLI for Lexmount cloud browsers"

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ cargo build --release
1414
Configuration is loaded from `LEXMOUNT_API_KEY`, `LEXMOUNT_PROJECT_ID`, optional
1515
`LEXMOUNT_BASE_URL` (default `https://api.lexmount.cn`), and optional
1616
`LEXMOUNT_REGION`. `browser-cli auth login` uses a loopback callback and PKCE;
17-
credentials are stored at `~/.config/lexmount/browser-cli/credentials.json` with
18-
mode `0600` on Unix and are never printed.
17+
pass `--client-name "<name>"` to identify the calling Agent in the approval page,
18+
or omit it to use `Agent`. Credentials are stored at
19+
`~/.config/lexmount/browser-cli/credentials.json` with mode `0600` on Unix and
20+
are never printed.
1921

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

23-
## WorkBuddy package
25+
## Agent Skill package
2426

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

@@ -35,6 +37,13 @@ release from Tencent Cloud COS and verifies its SHA-256 digest. Set
3537
`LEXMOUNT_BROWSER_CLI_VERSION` or `LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL` only
3638
when testing a different published release or mirror.
3739

40+
Agents resolve bundled scripts and binaries from the directory containing the
41+
loaded `SKILL.md`: Codex uses the absolute source path supplied in the Skill
42+
metadata, Claude Code uses `${CLAUDE_SKILL_DIR}`, and WorkBuddy/CodeBuddy uses
43+
`${CODEBUDDY_SKILL_DIR}`. These are host-level Skill locators, not installation
44+
or download inputs. Once started, the bootstrap and doctor scripts also locate
45+
the Skill directory from their own path.
46+
3847
Published binaries are intentionally limited to two targets: macOS arm64 and
3948
Windows x64. The macOS binary is signed with a Developer ID Application
4049
certificate, hardened-runtime enabled, and accepted by Apple's notarization

skills/lexmount-browser/SKILL.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,32 @@ description: Use Lexmount cloud browsers to open and interact with JavaScript-he
55

66
# Lexmount Browser
77

8+
Resolve `<skill-root>` to the directory containing this loaded `SKILL.md` with
9+
the current Agent's Skill locator:
10+
11+
- Codex: use the absolute `SKILL.md` source path supplied in the Skill metadata.
12+
- Claude Code: use `${CLAUDE_SKILL_DIR}`.
13+
- WorkBuddy/CodeBuddy: use `${CODEBUDDY_SKILL_DIR}`.
14+
15+
Do not infer `<skill-root>` from the working directory.
16+
817
Select the native Rust binary for the current platform:
918

10-
- macOS arm64: run `${CODEBUDDY_SKILL_DIR}/scripts/bootstrap.sh` when `${CODEBUDDY_SKILL_DIR}/bin/browser-cli` is missing, then use that file.
11-
- Windows x64: run `${CODEBUDDY_SKILL_DIR}/scripts/bootstrap.ps1` when `${CODEBUDDY_SKILL_DIR}/bin/browser-cli.exe` is missing, then use that file.
19+
- macOS arm64: run `sh "<skill-root>/scripts/bootstrap.sh"` when `<skill-root>/bin/browser-cli` is missing, then invoke `"<skill-root>/bin/browser-cli"`.
20+
- 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"`.
1221

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

15-
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.
27+
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`.
1628

1729
## Setup
1830

19-
1. On macOS arm64, run `bootstrap.sh` if `bin/browser-cli` is missing, then run `doctor.sh`.
20-
2. On Windows x64, run `bootstrap.ps1` if `bin/browser-cli.exe` is missing, then run `doctor.ps1`.
21-
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.
31+
1. Resolve `<skill-root>` from this `SKILL.md` and select the matching platform paths above.
32+
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.
33+
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.
2234
4. Run `browser-cli doctor` again. Continue only when `ready_for_browser_actions` is true.
2335

2436
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.

skills/lexmount-browser/references/authentication.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
Preferred login:
44

55
```text
6-
browser-cli auth login
6+
browser-cli auth login [--client-name "NAME"]
77
```
88

9+
The default client name is `Agent`. Quote and pass the current Agent's
10+
user-facing name when available; for example, WorkBuddy can pass
11+
`--client-name "WorkBuddy"`.
12+
913
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:
1014

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

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

17-
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.
21+
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.
1822

1923
Use `browser-cli auth logout` to remove only the local credential file. Environment variables are managed outside the CLI.

skills/lexmount-browser/references/commands.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# Command reference
22

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

57
```text
68
browser-cli doctor
79
browser-cli auth status
8-
browser-cli auth login
10+
browser-cli auth login [--client-name "NAME"]
911
1012
browser-cli session create [--browser-mode normal|light]
1113
[--context-id ID --context-mode read_write|read_only]

skills/lexmount-browser/references/troubleshooting.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Run `browser-cli doctor` first and use the failed check's message.
77
- `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.
88
- `timeout`: inspect session status and network access, then retry with a larger timeout.
99
- `cdp_error`: verify the session is active, inspect `session targets`, and take a snapshot before retrying the action.
10-
- command not found after bootstrap: use the Skill-local binary under `${CODEBUDDY_SKILL_DIR}/bin/`; no PATH change or restart is required.
10+
- 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.
11+
- 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.
1112

1213
Always close a newly created temporary session when abandoning a failed task.

skills/lexmount-browser/scripts/bootstrap.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function Invoke-Tls12Download {
1212
}
1313
}
1414

15-
$version = if ($env:LEXMOUNT_BROWSER_CLI_VERSION) { $env:LEXMOUNT_BROWSER_CLI_VERSION } else { "1.1.11" }
15+
$version = if ($env:LEXMOUNT_BROWSER_CLI_VERSION) { $env:LEXMOUNT_BROWSER_CLI_VERSION } else { "1.1.12" }
1616
$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" }
1717
$architecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
1818
if ($architecture -ne "AMD64") { throw "Only Windows x64 is supported" }

skills/lexmount-browser/scripts/bootstrap.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/bin/sh
22
set -eu
33

4-
version="${LEXMOUNT_BROWSER_CLI_VERSION:-1.1.11}"
4+
version="${LEXMOUNT_BROWSER_CLI_VERSION:-1.1.12}"
55
download_base_url="${LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL:-https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/browser-cli}"
66
repo="${download_base_url%/}/v${version}"
77
case "$(uname -s)-$(uname -m)" in

src/auth.rs

Lines changed: 114 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ use url::Url;
1717
use crate::{Error, Result};
1818

1919
pub const DEFAULT_CONNECT_BASE_URL: &str = "https://browser.lexmount.cn";
20+
pub const DEFAULT_CLIENT_NAME: &str = "Agent";
2021
pub const DEFAULT_SCOPES: &[&str] = &["browser:sessions", "browser:contexts", "browser:actions"];
22+
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>";
2123

2224
#[derive(Debug, Clone, Serialize, Deserialize)]
2325
pub struct Credentials {
@@ -143,6 +145,24 @@ pub fn login(
143145
timeout: Duration,
144146
open_browser: bool,
145147
path: Option<&Path>,
148+
) -> Result<Value> {
149+
login_with_client_name(
150+
project_id,
151+
DEFAULT_CLIENT_NAME,
152+
connect_base_url,
153+
timeout,
154+
open_browser,
155+
path,
156+
)
157+
}
158+
159+
pub fn login_with_client_name(
160+
project_id: Option<&str>,
161+
client_name: &str,
162+
connect_base_url: &str,
163+
timeout: Duration,
164+
open_browser: bool,
165+
path: Option<&Path>,
146166
) -> Result<Value> {
147167
let listener = TcpListener::bind("127.0.0.1:0")?;
148168
listener.set_nonblocking(true)?;
@@ -153,32 +173,14 @@ pub fn login(
153173
let verifier = random_urlsafe(32);
154174
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
155175
let state = random_urlsafe(24);
156-
let scopes = DEFAULT_SCOPES
157-
.iter()
158-
.map(|v| (*v).to_owned())
159-
.collect::<Vec<_>>();
160-
161-
let mut url = Url::parse(&format!(
162-
"{}/connect/codex",
163-
connect_base_url.trim_end_matches('/')
164-
))
165-
.map_err(|e| Error::Config(format!("invalid connect base URL: {e}")))?;
166-
{
167-
let mut q = url.query_pairs_mut();
168-
q.append_pair("source", "browser-cli")
169-
.append_pair("intent", "agent-browser-control")
170-
.append_pair("response", "code")
171-
.append_pair("expires_in", "7d")
172-
.append_pair("scope", &scopes.join(" "))
173-
.append_pair("redirect_uri", &redirect_uri)
174-
.append_pair("state", &state)
175-
.append_pair("code_challenge", &challenge)
176-
.append_pair("code_challenge_method", "S256")
177-
.append_pair("client_name", "WorkBuddy");
178-
if let Some(project_id) = project_id {
179-
q.append_pair("project_id", project_id);
180-
}
181-
}
176+
let url = authorization_url(
177+
project_id,
178+
client_name,
179+
connect_base_url,
180+
&redirect_uri,
181+
&state,
182+
&challenge,
183+
)?;
182184
if open_browser {
183185
open::that(url.as_str()).map_err(|e| Error::Io(std::io::Error::other(e)))?;
184186
}
@@ -198,13 +200,12 @@ pub fn login(
198200
.ok_or_else(|| Error::Config("invalid OAuth callback request".into()))?;
199201
let callback = Url::parse(&format!("http://127.0.0.1{target}"))
200202
.map_err(|e| Error::Config(format!("invalid OAuth callback: {e}")))?;
201-
let body = b"<!doctype html><meta charset=utf-8><title>Lexmount connected</title><h1>Lexmount connected</h1><p>You can return to WorkBuddy.</p>";
202203
write!(
203204
stream,
204205
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
205-
body.len()
206+
LOGIN_SUCCESS_PAGE.len()
206207
)?;
207-
stream.write_all(body)?;
208+
stream.write_all(LOGIN_SUCCESS_PAGE.as_bytes())?;
208209
break callback;
209210
}
210211
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && started.elapsed() < timeout => {
@@ -272,6 +273,39 @@ pub fn login(
272273
)
273274
}
274275

276+
fn authorization_url(
277+
project_id: Option<&str>,
278+
client_name: &str,
279+
connect_base_url: &str,
280+
redirect_uri: &str,
281+
state: &str,
282+
challenge: &str,
283+
) -> Result<Url> {
284+
let scopes = DEFAULT_SCOPES.join(" ");
285+
let mut url = Url::parse(&format!(
286+
"{}/connect/codex",
287+
connect_base_url.trim_end_matches('/')
288+
))
289+
.map_err(|e| Error::Config(format!("invalid connect base URL: {e}")))?;
290+
{
291+
let mut q = url.query_pairs_mut();
292+
q.append_pair("source", "browser-cli")
293+
.append_pair("intent", "agent-browser-control")
294+
.append_pair("response", "code")
295+
.append_pair("expires_in", "7d")
296+
.append_pair("scope", &scopes)
297+
.append_pair("redirect_uri", redirect_uri)
298+
.append_pair("state", state)
299+
.append_pair("code_challenge", challenge)
300+
.append_pair("code_challenge_method", "S256")
301+
.append_pair("client_name", client_name);
302+
if let Some(project_id) = project_id {
303+
q.append_pair("project_id", project_id);
304+
}
305+
}
306+
Ok(url)
307+
}
308+
275309
fn random_urlsafe(size: usize) -> String {
276310
let mut bytes = vec![0_u8; size];
277311
rand::rng().fill_bytes(&mut bytes);
@@ -330,7 +364,7 @@ fn extract_api_key(payload: &Value) -> Option<Credentials> {
330364
.or_else(|| value.get("expiresAt"))
331365
.and_then(Value::as_str)
332366
.map(str::to_owned),
333-
source: Some("connect_from_workbuddy".into()),
367+
source: Some("connect_from_browser_cli".into()),
334368
connect_base_url: Some(DEFAULT_CONNECT_BASE_URL.into()),
335369
created_at: None,
336370
});
@@ -352,12 +386,62 @@ fn is_internal_api_base_url(value: &str) -> bool {
352386
#[cfg(test)]
353387
mod tests {
354388
use super::*;
389+
355390
#[test]
356391
fn extracts_nested_credentials_without_leaking_secret() {
357392
let c = extract_api_key(&json!({"credential":{"projectId":"p1","apiKey":"secret","apiBaseUrl":"https://api.example","scope":"browser:sessions browser:actions"}})).unwrap();
358393
assert_eq!(c.project_id, "p1");
359394
assert_eq!(c.api_key, "secret");
360395
assert_eq!(c.scopes.len(), 2);
396+
assert_eq!(c.source.as_deref(), Some("connect_from_browser_cli"));
397+
}
398+
399+
#[test]
400+
fn authorization_url_encodes_custom_client_name() {
401+
let client_name = "Claude Desktop 中文";
402+
let url = authorization_url(
403+
Some("project-1"),
404+
client_name,
405+
"https://browser.example/",
406+
"http://127.0.0.1:1234/callback",
407+
"state",
408+
"challenge",
409+
)
410+
.unwrap();
411+
412+
assert!(
413+
url.as_str()
414+
.contains("client_name=Claude+Desktop+%E4%B8%AD%E6%96%87")
415+
);
416+
assert_eq!(
417+
url.query_pairs()
418+
.find(|(key, _)| key.as_ref() == "client_name")
419+
.map(|(_, value)| value.into_owned())
420+
.as_deref(),
421+
Some(client_name)
422+
);
423+
}
424+
425+
#[test]
426+
fn login_success_page_is_agent_agnostic() {
427+
assert!(
428+
!LOGIN_SUCCESS_PAGE
429+
.to_ascii_lowercase()
430+
.contains("workbuddy")
431+
);
432+
assert!(LOGIN_SUCCESS_PAGE.contains("return to your agent"));
433+
}
434+
435+
#[test]
436+
fn legacy_login_api_signature_is_preserved() {
437+
type LegacyLogin = for<'a, 'b, 'c> fn(
438+
Option<&'a str>,
439+
&'b str,
440+
Duration,
441+
bool,
442+
Option<&'c Path>,
443+
) -> Result<Value>;
444+
let _: LegacyLogin = login;
361445
}
362446

363447
#[test]

0 commit comments

Comments
 (0)