From f31fc240ce6e67b3cfe1f5bcfe0ff9fd959296bd Mon Sep 17 00:00:00 2001 From: bitpshr Date: Wed, 21 Oct 2026 21:58:47 -0400 Subject: [PATCH 1/6] fix: onboard finishes the sign-in when the token can't be saved The token exchange can succeed while saving the token fails (a keychain that refuses the CLI, a sandboxed home folder). The browser then landed on the server_error page and the agent was told the sign-in didn't finish, even though onboard only needs the token for one account check. Keep the token in memory for that check, note the failed save in the worker log, and send the browser to the success page. --- cli/elevenlabs/workflow/loopback.rs | 38 +++++----- cli/elevenlabs/workflow/onboard/auth.rs | 17 ++--- cli/elevenlabs/workflow/onboard/connect.rs | 26 +++---- tests/onboard_test.rs | 81 ++++++++++++++++++++-- 4 files changed, 117 insertions(+), 45 deletions(-) diff --git a/cli/elevenlabs/workflow/loopback.rs b/cli/elevenlabs/workflow/loopback.rs index 8074971..21469c0 100644 --- a/cli/elevenlabs/workflow/loopback.rs +++ b/cli/elevenlabs/workflow/loopback.rs @@ -62,7 +62,8 @@ pub(super) struct Login<'a> { } /// Sign in through the browser and store the token where `auth login` does. -pub(super) async fn sign_in(login: Login<'_>) -> Result<(), CliError> { +/// Returns the access token, which works even when the store refused it. +pub(super) async fn sign_in(login: Login<'_>) -> Result { let verifier = random_token(); let state = random_token(); let (listeners, port) = bind(login.redirect_ports).await?; @@ -75,22 +76,27 @@ pub(super) async fn sign_in(login: Login<'_>) -> Result<(), CliError> { } let (code, mut browser) = wait_for_callback(listeners, &state, login.error_url, login.timeout).await?; - // The browser waits on the result page until the token is stored. - let stored = async { - let token = exchange( - login.token_url, - login.client_id, - &code, - &verifier, - &redirect_uri, - ) - .await?; - active_store().set(login.cli_name, login.scheme, &token.to_keyring_value()?) - }; - match stored.await { - Ok(()) => { + // The browser waits on the result page until the code is exchanged. + let token = exchange( + login.token_url, + login.client_id, + &code, + &verifier, + &redirect_uri, + ) + .await; + match token { + Ok(token) => { + // A keychain that refuses this binary or a sandbox, say. The + // sign-in still worked; only `auth` commands run later won't see it. + let stored = token + .to_keyring_value() + .and_then(|value| active_store().set(login.cli_name, login.scheme, &value)); + if let Err(e) = stored { + eprintln!("Signed in, but the sign-in couldn't be saved for later commands: {e}"); + } let _ = redirect(&mut browser, login.success_url).await; - Ok(()) + Ok(token.access_token) } Err(e) => { let page = error_page(login.error_url, "server_error", Some(AFTER_CALLBACK_FAILED)); diff --git a/cli/elevenlabs/workflow/onboard/auth.rs b/cli/elevenlabs/workflow/onboard/auth.rs index aefa960..56f3963 100644 --- a/cli/elevenlabs/workflow/onboard/auth.rs +++ b/cli/elevenlabs/workflow/onboard/auth.rs @@ -5,7 +5,6 @@ use std::sync::OnceLock; use std::time::Duration; -use fern_cli_sdk::auth::{active_store, TokenBundle}; use fern_cli_sdk::error::CliError; use fern_cli_sdk::openapi::AppContext; use serde_json::Value; @@ -86,12 +85,12 @@ fn parse_sign_in(main_rs: &str) -> Result { }) } -/// Sign in through the browser, asking the page for a key. +/// Sign in through the browser, asking the page for a key. Returns the access token. pub(super) fn sign_in( ctx: &AppContext, authorize_url: &str, no_browser: bool, -) -> Result<(), CliError> { +) -> Result { let config = sign_in_config()?; // A local --base-url also moves the token endpoint, for testing against a local backend. let oauth_base = ctx @@ -116,18 +115,13 @@ pub(super) fn sign_in( })) } -pub(super) fn stored_access_token() -> Option { - let scheme = &sign_in_config().ok()?.scheme; - let raw = active_store().get(CLI_NAME, scheme).ok().flatten()?; - let token = TokenBundle::parse_or_raw(&raw).access_token; - (!token.is_empty()).then_some(token) -} - /// `Err` when the signed-in account itself cannot be identified; `Ok(false)` only -/// when it can and the key does not belong to it. +/// when it can and the key does not belong to it. `token` is the sign-in's +/// access token. pub(super) fn key_matches_sign_in( client: &reqwest::Client, base: &str, + token: &str, key: &str, ) -> Result { let unknown = || { @@ -135,7 +129,6 @@ pub(super) fn key_matches_sign_in( "the signed-in account could not be identified" )) }; - let token = stored_access_token().ok_or_else(unknown)?; let bearer = format!("Bearer {token}"); let signed_in = account_for(client, base, "authorization", &bearer)?.ok_or_else(unknown)?; Ok(account_for(client, base, "xi-api-key", key)? == Some(signed_in)) diff --git a/cli/elevenlabs/workflow/onboard/connect.rs b/cli/elevenlabs/workflow/onboard/connect.rs index ecd043d..a256212 100644 --- a/cli/elevenlabs/workflow/onboard/connect.rs +++ b/cli/elevenlabs/workflow/onboard/connect.rs @@ -235,17 +235,19 @@ fn sign_in_and_store( on_signed_in: impl FnOnce(), ) -> Result { let client = ctx.http_config().build_client()?; - let signed_in = auth::sign_in(ctx, &authorize_url(matches)?, project.no_browser); - if let Err(error) = signed_in { - // The page may have copied a key before the callback failed; nothing - // can vouch for it now, so it is cleared, not stored. - Clipboard::open().discard_key(); - return Ok(failed( - project, - &error.to_string(), - matches.get_flag("replace"), - )); - } + let token = match auth::sign_in(ctx, &authorize_url(matches)?, project.no_browser) { + Ok(token) => token, + Err(error) => { + // The page may have copied a key before the callback failed; nothing + // can vouch for it now, so it is cleared, not stored. + Clipboard::open().discard_key(); + return Ok(failed( + project, + &error.to_string(), + matches.get_flag("replace"), + )); + } + }; on_signed_in(); let mut clipboard = Clipboard::open(); let clip = clipboard.read(); @@ -255,7 +257,7 @@ fn sign_in_and_store( Clip::Nothing => String::new(), }; let outcome = outcome_of(clip, |key| { - auth::key_matches_sign_in(&client, &auth::api_base(ctx), key) + auth::key_matches_sign_in(&client, &auth::api_base(ctx), &token, key) }); let reply = reply_for(project, &outcome); // Whatever the outcome, and even if storing failed, a key never stays on the clipboard. diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 4ce9189..ff42cbe 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -1066,11 +1066,12 @@ fn decline(url: &str) { } /// The browser's side of an approval: the page sends the code to the callback. -fn approve(url: &str) { - callback(url, "code=approved"); +/// Returns what the sign-in answered the browser. +fn approve(url: &str) -> String { + callback(url, "code=approved") } -fn callback(url: &str, params: &str) { +fn callback(url: &str, params: &str) -> String { let redirect = reqwest::Url::parse(&query(url, "redirect_uri")).unwrap(); let authority = format!( "{}:{}", @@ -1088,10 +1089,12 @@ fn callback(url: &str, params: &str) { for _ in 0..10 { let sent = std::net::TcpStream::connect(&authority).and_then(|mut stream| { stream.write_all(request.as_bytes())?; - std::io::Read::read_to_end(&mut stream, &mut Vec::new()) + let mut answer = Vec::new(); + std::io::Read::read_to_end(&mut stream, &mut answer)?; + Ok(answer) }); match sent { - Ok(_) => return, + Ok(answer) => return String::from_utf8_lossy(&answer).into_owned(), Err(e) => last = Some(e), } std::thread::sleep(Duration::from_millis(300)); @@ -1277,3 +1280,71 @@ async fn a_projects_env_cannot_reach_the_git_the_sign_in_runs() { "the .env's GIT_TRACE reached the worker's git" ); } + +/// Where the file credential store keeps sign-ins for a `HOME` of `home`. +fn credential_store(home: &Path) -> PathBuf { + if cfg!(target_os = "macos") { + home.join("Library/Application Support/elevenlabs") + } else if cfg!(windows) { + home.join("AppData").join("Roaming").join("elevenlabs") + } else { + home.join(".config/elevenlabs") + } +} + +#[tokio::test] +async fn a_sign_in_the_credential_store_refuses_still_finishes() { + let _turn = signing_in().await; + if callback_ports_busy() { + eprintln!("skipped: another sign-in is listening on the callback ports"); + return; + } + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh" + }))) + .mount(&server) + .await; + let (home, cwd) = temp(); + // A file where the store keeps its folder, so saving the sign-in fails, as + // a keychain that refuses the CLI does. + let store = credential_store(home.path()); + std::fs::create_dir_all(store.parent().unwrap()).unwrap(); + std::fs::write(&store, "").unwrap(); + let authorize = format!("{}/app/oauth/authorize", server.uri()); + let first = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &[ + "onboard", + "connect", + "--no-browser", + "--authorize-url", + &authorize, + ], + )); + assert_eq!(first["next"], "wait", "{first}"); + let (session, url) = approval(home.path()); + let page = approve(&url); + assert!( + page.contains("Location: https://elevenlabs.io/app/oauth/cli/success\r\n"), + "{page}" + ); + let deadline = Instant::now() + Duration::from_secs(60); + let done = loop { + let r = connect(home.path(), cwd.path(), &["--wait", "--no-browser"]); + if r["next"] != "wait" || Instant::now() >= deadline { + break r; + } + }; + // Signed in: no key reached the clipboard, so the env file was prepared for one. + assert_eq!(done["details"]["key_status"], "no_key", "{done}"); + let log = std::fs::read_to_string(session.join("worker.log")).unwrap(); + assert!(log.contains("couldn't be saved"), "{log}"); +} From 039fa6bb4d262fa0ef4786e28870eb3e9af7de0b Mon Sep 17 00:00:00 2001 From: bitpshr Date: Wed, 21 Oct 2026 22:21:20 -0400 Subject: [PATCH 2/6] test: the clipboard key is checked with the token the sign-in returned Also say plainly what a failed save leaves behind: commands that read the stored sign-in keep the old one, which may be another account's. --- cli/elevenlabs/workflow/loopback.rs | 6 ++++-- cli/elevenlabs/workflow/onboard/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cli/elevenlabs/workflow/loopback.rs b/cli/elevenlabs/workflow/loopback.rs index 21469c0..046d014 100644 --- a/cli/elevenlabs/workflow/loopback.rs +++ b/cli/elevenlabs/workflow/loopback.rs @@ -87,8 +87,10 @@ pub(super) async fn sign_in(login: Login<'_>) -> Result { .await; match token { Ok(token) => { - // A keychain that refuses this binary or a sandbox, say. The - // sign-in still worked; only `auth` commands run later won't see it. + // A keychain that refuses this binary, or a sandbox. The sign-in + // still worked, but commands that read the stored sign-in won't + // see it: they keep whatever was stored before, which may be + // another account's token. let stored = token .to_keyring_value() .and_then(|value| active_store().set(login.cli_name, login.scheme, &value)); diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs index 02ddde3..23ed27c 100644 --- a/cli/elevenlabs/workflow/onboard/tests.rs +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -1135,3 +1135,25 @@ fn gitignore_entries_match_only_the_env_file() { "{status}" ); } + +#[tokio::test(flavor = "multi_thread")] +async fn a_clipboard_key_is_checked_with_the_token_the_sign_in_returned() { + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + const KEY: &str = "sk_0123456789abcdef0123456789abcdef01234567"; + let server = MockServer::start().await; + for (name, value) in [("authorization", "Bearer token"), ("xi-api-key", KEY)] { + Mock::given(method("GET")) + .and(path("/v1/user")) + .and(header(name, value)) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"user_id": "user"})), + ) + .mount(&server) + .await; + } + let client = reqwest::Client::new(); + assert!(key_matches_sign_in(&client, &server.uri(), "token", KEY).unwrap()); + // Any other token opens no account here, so nothing vouches for the key. + assert!(key_matches_sign_in(&client, &server.uri(), "other", KEY).is_err()); +} From c0e9ea885ddf65d9bcce2f84bdbe1f303e273ded Mon Sep 17 00:00:00 2001 From: bitpshr Date: Wed, 21 Oct 2026 20:17:22 -0400 Subject: [PATCH 3/6] feat: the approval page and the API learn the OS and environment the CLI runs in --- cli/elevenlabs/workflow/api.rs | 18 ++-- cli/elevenlabs/workflow/mod.rs | 1 + cli/elevenlabs/workflow/onboard/auth.rs | 17 +++- cli/elevenlabs/workflow/onboard/browser.rs | 10 +- cli/elevenlabs/workflow/onboard/tests.rs | 12 +++ cli/elevenlabs/workflow/platform.rs | 105 +++++++++++++++++++++ 6 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 cli/elevenlabs/workflow/platform.rs diff --git a/cli/elevenlabs/workflow/api.rs b/cli/elevenlabs/workflow/api.rs index dc6c827..9117760 100644 --- a/cli/elevenlabs/workflow/api.rs +++ b/cli/elevenlabs/workflow/api.rs @@ -58,7 +58,8 @@ fn advance_cursor(resp: &Value, seen: &mut std::collections::HashSet) -> /// The tag is **always a static string** chosen by the caller. It must never be /// derived from argv: positional tokens carry user data (`agents test my-agent` /// would put the agent's name in the User-Agent and from there into request -/// logs). +/// logs). The platform tags after it come from a fixed set (see +/// [`super::platform`]). /// /// Appends rather than replaces, so a consumer's own `ELEVENLABS_VIA` survives. /// Restores the previous value on drop, so one command cannot leak its tag into @@ -82,7 +83,7 @@ fn suffix_env_key() -> String { pub fn command_scope(command: &'static str) -> CommandScope { let key = suffix_env_key(); let previous = std::env::var(&key).ok(); - let tag = format!("cmd/{command}"); + let tag = format!("cmd/{command} {}", super::platform::tags()); let combined = match previous.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(existing) => format!("{existing} {tag}"), None => tag, @@ -607,7 +608,8 @@ mod tests { with_env(|key| { { let _scope = command_scope("agents.push"); - assert_eq!(std::env::var(key).as_deref(), Ok("cmd/agents.push")); + let tag = std::env::var(key).unwrap(); + assert!(tag.starts_with("cmd/agents.push os/"), "{tag}"); } // Restored to absent, so one command cannot leak its tag into the next. assert!(std::env::var(key).is_err()); @@ -620,9 +622,10 @@ mod tests { std::env::set_var(key, "partner-app/3.1"); { let _scope = command_scope("tools.pull"); - assert_eq!( - std::env::var(key).as_deref(), - Ok("partner-app/3.1 cmd/tools.pull") + let tag = std::env::var(key).unwrap(); + assert!( + tag.starts_with("partner-app/3.1 cmd/tools.pull os/"), + "{tag}" ); } assert_eq!(std::env::var(key).as_deref(), Ok("partner-app/3.1")); @@ -634,7 +637,8 @@ mod tests { with_env(|key| { std::env::set_var(key, " "); let _scope = command_scope("tests.add"); - assert_eq!(std::env::var(key).as_deref(), Ok("cmd/tests.add")); + let tag = std::env::var(key).unwrap(); + assert!(tag.starts_with("cmd/tests.add os/"), "{tag}"); }); } diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index 64b340d..561a9a7 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -21,6 +21,7 @@ mod flows_wait; mod intent; mod loopback; mod onboard; +mod platform; mod project; mod residency; mod say; diff --git a/cli/elevenlabs/workflow/onboard/auth.rs b/cli/elevenlabs/workflow/onboard/auth.rs index 56f3963..98421de 100644 --- a/cli/elevenlabs/workflow/onboard/auth.rs +++ b/cli/elevenlabs/workflow/onboard/auth.rs @@ -10,6 +10,7 @@ use fern_cli_sdk::openapi::AppContext; use serde_json::Value; use super::super::loopback; +use super::super::platform; use super::super::say::run_async as block_on; use super::super::settings; use super::session::SIGN_IN_MINUTES; @@ -98,6 +99,7 @@ pub(super) fn sign_in( .and_then(loopback) .unwrap_or_else(|| config.token_url.trim_end_matches("/v1/oauth/token").into()); let token_url = format!("{oauth_base}/v1/oauth/token"); + let params = authorize_params(platform::detect()); block_on(loopback::sign_in(loopback::Login { cli_name: CLI_NAME, scheme: &config.scheme, @@ -109,12 +111,25 @@ pub(super) fn sign_in( redirect_ports: &config.redirect_ports, success_url: &config.success_url, error_url: &config.error_url, - params: &[("key_handoff", "clipboard")], + params: ¶ms, no_browser, timeout: Duration::from_secs(SIGN_IN_MINUTES * 60), })) } +/// What the sign-in adds to the approval link: the page should hand over a +/// key, and where the CLI runs, which the page records for a sign-in that +/// never gets to make an API call. +pub(super) fn authorize_params( + (os, env): (&'static str, Option<&'static str>), +) -> Vec<(&'static str, &'static str)> { + let mut params = vec![("key_handoff", "clipboard"), ("cli_os", os)]; + if let Some(env) = env { + params.push(("cli_env", env)); + } + params +} + /// `Err` when the signed-in account itself cannot be identified; `Ok(false)` only /// when it can and the key does not belong to it. `token` is the sign-in's /// access token. diff --git a/cli/elevenlabs/workflow/onboard/browser.rs b/cli/elevenlabs/workflow/onboard/browser.rs index 50e4f0e..ea853a6 100644 --- a/cli/elevenlabs/workflow/onboard/browser.rs +++ b/cli/elevenlabs/workflow/onboard/browser.rs @@ -1,7 +1,7 @@ //! Whether a browser this process opens lands on this machine, so the //! approval page's redirect and clipboard can reach the CLI. -use std::path::Path; +use super::super::platform::in_container; /// False over SSH, in a container, or on Linux with no display. pub(super) fn reachable_in( @@ -20,15 +20,9 @@ pub(super) fn reachable_in( } pub(super) fn reachable() -> bool { - let in_container = Path::new("/.dockerenv").exists() - || std::fs::read_to_string("/proc/1/cgroup").is_ok_and(|c| { - ["docker", "containerd", "kubepods"] - .iter() - .any(|m| c.contains(m)) - }); reachable_in( std::env::consts::OS, &|n| std::env::var(n).ok(), - in_container, + in_container(), ) } diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs index 23ed27c..d2f9f5b 100644 --- a/cli/elevenlabs/workflow/onboard/tests.rs +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -1157,3 +1157,15 @@ async fn a_clipboard_key_is_checked_with_the_token_the_sign_in_returned() { // Any other token opens no account here, so nothing vouches for the key. assert!(key_matches_sign_in(&client, &server.uri(), "other", KEY).is_err()); } + +#[test] +fn the_approval_link_asks_for_a_key_and_says_where_the_cli_runs() { + assert_eq!( + authorize_params(("linux", Some("wsl"))), + [("key_handoff", "clipboard"), ("cli_os", "linux"), ("cli_env", "wsl")] + ); + assert_eq!( + authorize_params(("windows", None)), + [("key_handoff", "clipboard"), ("cli_os", "windows")] + ); +} diff --git a/cli/elevenlabs/workflow/platform.rs b/cli/elevenlabs/workflow/platform.rs new file mode 100644 index 0000000..1b72878 --- /dev/null +++ b/cli/elevenlabs/workflow/platform.rs @@ -0,0 +1,105 @@ +//! Roughly where the CLI runs: the OS, and whether it is over SSH, in a +//! container or in WSL. The API is told in the User-Agent (see +//! [`super::api::command_scope`]), so sign-in and request problems can be +//! counted per platform. Nothing else about the machine is sent. + +use std::path::Path; + +/// `os/`, then `env/` when one applies: the User-Agent tags. +pub(super) fn tags() -> String { + match detect() { + (os, Some(env)) => format!("os/{os} env/{env}"), + (os, None) => format!("os/{os}"), + } +} + +/// The OS (`windows`, `macos`, `linux` or `other`), and how the CLI is +/// reached there when not directly (`ssh`, `container` or `wsl`). +pub(super) fn detect() -> (&'static str, Option<&'static str>) { + detect_for( + std::env::consts::OS, + &|name| std::env::var(name).ok(), + in_container(), + ) +} + +pub(super) fn detect_for( + os: &str, + var: &dyn Fn(&str) -> Option, + in_container: bool, +) -> (&'static str, Option<&'static str>) { + let set = |name: &str| var(name).is_some_and(|v| !v.trim().is_empty()); + let os = match os { + "windows" => "windows", + "macos" => "macos", + "linux" => "linux", + _ => "other", + }; + let env = if set("SSH_CONNECTION") || set("SSH_TTY") || set("SSH_CLIENT") { + Some("ssh") + } else if in_container || set("CODESPACES") || set("REMOTE_CONTAINERS") { + Some("container") + } else if os == "linux" && set("WSL_DISTRO_NAME") { + Some("wsl") + } else { + None + }; + (os, env) +} + +/// `/.dockerenv`, or a container runtime in PID 1's cgroup. +pub(super) fn in_container() -> bool { + Path::new("/.dockerenv").exists() + || std::fs::read_to_string("/proc/1/cgroup").is_ok_and(|c| { + ["docker", "containerd", "kubepods"] + .iter() + .any(|m| c.contains(m)) + }) +} + +#[cfg(test)] +mod tests { + use super::detect_for; + + fn vars(set: &'static [&'static str]) -> impl Fn(&str) -> Option { + move |name| set.contains(&name).then(|| "1".to_string()) + } + + #[test] + fn the_os_and_how_the_cli_is_reached() { + assert_eq!(detect_for("windows", &vars(&[]), false), ("windows", None)); + assert_eq!( + detect_for("macos", &vars(&["SSH_TTY"]), false), + ("macos", Some("ssh")) + ); + assert_eq!( + detect_for("linux", &vars(&["WSL_DISTRO_NAME"]), false), + ("linux", Some("wsl")) + ); + // Only Linux runs under WSL. + assert_eq!( + detect_for("windows", &vars(&["WSL_DISTRO_NAME"]), false), + ("windows", None) + ); + assert_eq!( + detect_for("linux", &vars(&[]), true), + ("linux", Some("container")) + ); + assert_eq!( + detect_for("linux", &vars(&["REMOTE_CONTAINERS"]), false), + ("linux", Some("container")) + ); + // SSH into WSL is still a remote shell. + assert_eq!( + detect_for("linux", &vars(&["SSH_CLIENT", "WSL_DISTRO_NAME"]), false), + ("linux", Some("ssh")) + ); + assert_eq!(detect_for("freebsd", &vars(&[]), false), ("other", None)); + } + + #[test] + fn a_blank_variable_is_not_set() { + let blank = |name: &str| (name == "SSH_TTY").then(|| " ".to_string()); + assert_eq!(detect_for("macos", &blank, false), ("macos", None)); + } +} From 1fbb6dad2eb700cdc32d588a17dfcf944c1532ef Mon Sep 17 00:00:00 2001 From: bitpshr Date: Thu, 22 Oct 2026 09:46:42 -0400 Subject: [PATCH 4/6] fix: one check for SSH and containers, Podman and Kubernetes pods included; test the platform on the approval link --- cli/elevenlabs/workflow/onboard/browser.rs | 14 +++++++------- cli/elevenlabs/workflow/onboard/tests.rs | 10 +++++++++- cli/elevenlabs/workflow/platform.rs | 14 ++++++++++++-- tests/onboard_test.rs | 5 +++++ 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/cli/elevenlabs/workflow/onboard/browser.rs b/cli/elevenlabs/workflow/onboard/browser.rs index ea853a6..1cff0c2 100644 --- a/cli/elevenlabs/workflow/onboard/browser.rs +++ b/cli/elevenlabs/workflow/onboard/browser.rs @@ -1,7 +1,7 @@ //! Whether a browser this process opens lands on this machine, so the //! approval page's redirect and clipboard can reach the CLI. -use super::super::platform::in_container; +use super::super::platform::{detect_for, in_container}; /// False over SSH, in a container, or on Linux with no display. pub(super) fn reachable_in( @@ -10,13 +10,13 @@ pub(super) fn reachable_in( in_container: bool, ) -> bool { let set = |name: &str| var(name).is_some_and(|v| !v.trim().is_empty()); - if set("SSH_CONNECTION") || set("SSH_TTY") || set("SSH_CLIENT") { - return false; + let (os, env) = detect_for(os, var, in_container); + match env { + Some("ssh" | "container") => false, + // WSL: the browser opens on Windows. + Some(_) => true, + None => os != "linux" || set("DISPLAY") || set("WAYLAND_DISPLAY"), } - if in_container || set("CODESPACES") || set("REMOTE_CONTAINERS") { - return false; - } - os != "linux" || set("WSL_DISTRO_NAME") || set("DISPLAY") || set("WAYLAND_DISPLAY") } pub(super) fn reachable() -> bool { diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs index d2f9f5b..8ae5c6c 100644 --- a/cli/elevenlabs/workflow/onboard/tests.rs +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -180,6 +180,10 @@ fn a_browser_reaches_a_laptop_but_not_a_remote_box() { assert!(reachable_in("linux", &only("WSL_DISTRO_NAME"), false)); assert!(!reachable_in("linux", &only("DISPLAY"), true), "container"); assert!(!reachable_in("linux", &only("CODESPACES"), false)); + assert!( + !reachable_in("linux", &only("KUBERNETES_SERVICE_HOST"), false), + "a pod" + ); } /// The developer's own git config (a global excludes file, say) must not @@ -1162,7 +1166,11 @@ async fn a_clipboard_key_is_checked_with_the_token_the_sign_in_returned() { fn the_approval_link_asks_for_a_key_and_says_where_the_cli_runs() { assert_eq!( authorize_params(("linux", Some("wsl"))), - [("key_handoff", "clipboard"), ("cli_os", "linux"), ("cli_env", "wsl")] + [ + ("key_handoff", "clipboard"), + ("cli_os", "linux"), + ("cli_env", "wsl") + ] ); assert_eq!( authorize_params(("windows", None)), diff --git a/cli/elevenlabs/workflow/platform.rs b/cli/elevenlabs/workflow/platform.rs index 1b72878..1dd5ceb 100644 --- a/cli/elevenlabs/workflow/platform.rs +++ b/cli/elevenlabs/workflow/platform.rs @@ -37,7 +37,11 @@ pub(super) fn detect_for( }; let env = if set("SSH_CONNECTION") || set("SSH_TTY") || set("SSH_CLIENT") { Some("ssh") - } else if in_container || set("CODESPACES") || set("REMOTE_CONTAINERS") { + } else if in_container + || set("CODESPACES") + || set("REMOTE_CONTAINERS") + || set("KUBERNETES_SERVICE_HOST") + { Some("container") } else if os == "linux" && set("WSL_DISTRO_NAME") { Some("wsl") @@ -47,9 +51,11 @@ pub(super) fn detect_for( (os, env) } -/// `/.dockerenv`, or a container runtime in PID 1's cgroup. +/// `/.dockerenv`, Podman's `/run/.containerenv`, or a container runtime in +/// PID 1's cgroup. pub(super) fn in_container() -> bool { Path::new("/.dockerenv").exists() + || Path::new("/run/.containerenv").exists() || std::fs::read_to_string("/proc/1/cgroup").is_ok_and(|c| { ["docker", "containerd", "kubepods"] .iter() @@ -89,6 +95,10 @@ mod tests { detect_for("linux", &vars(&["REMOTE_CONTAINERS"]), false), ("linux", Some("container")) ); + assert_eq!( + detect_for("linux", &vars(&["KUBERNETES_SERVICE_HOST"]), false), + ("linux", Some("container")) + ); // SSH into WSL is still a remote shell. assert_eq!( detect_for("linux", &vars(&["SSH_CLIENT", "WSL_DISTRO_NAME"]), false), diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index ff42cbe..54c2491 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -1169,6 +1169,11 @@ async fn connect_signs_in_in_the_background_and_every_waiter_gets_the_result() { let worker = std::fs::read_to_string(session.join("worker.pid")).unwrap(); assert!(url.starts_with(&authorize), "{url}"); assert_eq!(query(&url, "key_handoff"), "clipboard"); + // The approval page records where the CLI runs. + assert!( + ["windows", "macos", "linux", "other"].contains(&query(&url, "cli_os").as_str()), + "{url}" + ); assert!( !url.contains("product="), "one key for every product: {url}" From 8675b067b41ec1ce1a42bd1faaa913275ff0e381 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Thu, 22 Oct 2026 09:58:06 -0400 Subject: [PATCH 5/6] fix: Podman and Kubernetes pods count for the platform tag, not for whether a browser can reach the CLI --- cli/elevenlabs/workflow/onboard/tests.rs | 4 ---- cli/elevenlabs/workflow/platform.rs | 23 ++++++++++------------- tests/onboard_test.rs | 9 +++++---- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs index 8ae5c6c..ac46752 100644 --- a/cli/elevenlabs/workflow/onboard/tests.rs +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -180,10 +180,6 @@ fn a_browser_reaches_a_laptop_but_not_a_remote_box() { assert!(reachable_in("linux", &only("WSL_DISTRO_NAME"), false)); assert!(!reachable_in("linux", &only("DISPLAY"), true), "container"); assert!(!reachable_in("linux", &only("CODESPACES"), false)); - assert!( - !reachable_in("linux", &only("KUBERNETES_SERVICE_HOST"), false), - "a pod" - ); } /// The developer's own git config (a global excludes file, say) must not diff --git a/cli/elevenlabs/workflow/platform.rs b/cli/elevenlabs/workflow/platform.rs index 1dd5ceb..ff8a4f6 100644 --- a/cli/elevenlabs/workflow/platform.rs +++ b/cli/elevenlabs/workflow/platform.rs @@ -16,10 +16,17 @@ pub(super) fn tags() -> String { /// The OS (`windows`, `macos`, `linux` or `other`), and how the CLI is /// reached there when not directly (`ssh`, `container` or `wsl`). pub(super) fn detect() -> (&'static str, Option<&'static str>) { + // Podman and Kubernetes count here, but not for whether a browser can + // reach the CLI: toolbox and distrobox are Podman containers that share + // the host's network and display, and telepresence copies a pod's + // environment onto a laptop. + let in_container = in_container() + || Path::new("/run/.containerenv").exists() + || std::env::var_os("KUBERNETES_SERVICE_HOST").is_some(); detect_for( std::env::consts::OS, &|name| std::env::var(name).ok(), - in_container(), + in_container, ) } @@ -37,11 +44,7 @@ pub(super) fn detect_for( }; let env = if set("SSH_CONNECTION") || set("SSH_TTY") || set("SSH_CLIENT") { Some("ssh") - } else if in_container - || set("CODESPACES") - || set("REMOTE_CONTAINERS") - || set("KUBERNETES_SERVICE_HOST") - { + } else if in_container || set("CODESPACES") || set("REMOTE_CONTAINERS") { Some("container") } else if os == "linux" && set("WSL_DISTRO_NAME") { Some("wsl") @@ -51,11 +54,9 @@ pub(super) fn detect_for( (os, env) } -/// `/.dockerenv`, Podman's `/run/.containerenv`, or a container runtime in -/// PID 1's cgroup. +/// `/.dockerenv`, or a container runtime in PID 1's cgroup. pub(super) fn in_container() -> bool { Path::new("/.dockerenv").exists() - || Path::new("/run/.containerenv").exists() || std::fs::read_to_string("/proc/1/cgroup").is_ok_and(|c| { ["docker", "containerd", "kubepods"] .iter() @@ -95,10 +96,6 @@ mod tests { detect_for("linux", &vars(&["REMOTE_CONTAINERS"]), false), ("linux", Some("container")) ); - assert_eq!( - detect_for("linux", &vars(&["KUBERNETES_SERVICE_HOST"]), false), - ("linux", Some("container")) - ); // SSH into WSL is still a remote shell. assert_eq!( detect_for("linux", &vars(&["SSH_CLIENT", "WSL_DISTRO_NAME"]), false), diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 54c2491..75b5707 100644 --- a/tests/onboard_test.rs +++ b/tests/onboard_test.rs @@ -1170,10 +1170,11 @@ async fn connect_signs_in_in_the_background_and_every_waiter_gets_the_result() { assert!(url.starts_with(&authorize), "{url}"); assert_eq!(query(&url, "key_handoff"), "clipboard"); // The approval page records where the CLI runs. - assert!( - ["windows", "macos", "linux", "other"].contains(&query(&url, "cli_os").as_str()), - "{url}" - ); + let this_os = match std::env::consts::OS { + os @ ("windows" | "macos" | "linux") => os, + _ => "other", + }; + assert_eq!(query(&url, "cli_os"), this_os, "{url}"); assert!( !url.contains("product="), "one key for every product: {url}" From 1dad9e7b1384413935c5a7df84b3fd1957100ffe Mon Sep 17 00:00:00 2001 From: bitpshr Date: Thu, 22 Oct 2026 12:00:12 -0400 Subject: [PATCH 6/6] fix: onboard reads the clipboard again when another program holds it On Windows a clipboard read gives up after about 30 ms when another program has the clipboard open, and the sign-in then told the developer no key had been copied. The hand-off read now tries again for up to 2 s, which also covers a browser copy that lands late, and so does clearing the key once it is stored. A clipboard that never opens gets its own status, clipboard_unreadable, and the reply says the copied key may still be on the clipboard. --- cli/elevenlabs/workflow/onboard/clipboard.rs | 83 ++++++++++++++++++-- cli/elevenlabs/workflow/onboard/connect.rs | 11 ++- cli/elevenlabs/workflow/onboard/tests.rs | 50 +++++++++++- 3 files changed, 134 insertions(+), 10 deletions(-) diff --git a/cli/elevenlabs/workflow/onboard/clipboard.rs b/cli/elevenlabs/workflow/onboard/clipboard.rs index a21b9f0..072dfab 100644 --- a/cli/elevenlabs/workflow/onboard/clipboard.rs +++ b/cli/elevenlabs/workflow/onboard/clipboard.rs @@ -1,5 +1,7 @@ //! What the approval page left on the clipboard. +use std::time::{Duration, Instant}; + /// The page's answer when it did not copy a key, written as `elevenlabs:`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum Marker { @@ -29,12 +31,14 @@ impl Marker { } } -/// A key, one of the page's markers, or noise. +/// A key, one of the page's markers, noise, or no answer at all. #[derive(Debug, PartialEq, Eq)] pub(super) enum Clip { Key(String), Marker(Marker), Nothing, + /// The clipboard couldn't be read, so a key may still be on it. + Unreadable, } pub(super) fn classify(text: Option) -> Clip { @@ -65,6 +69,49 @@ fn is_key(text: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') } +pub(super) fn clip_of(text: Result) -> Clip { + match text { + Ok(text) => classify(Some(text)), + Err(e) if unreadable(&e) => Clip::Unreadable, + Err(_) => Clip::Nothing, + } +} + +/// The clipboard didn't open, as opposed to holding no text (or text that +/// isn't UTF-8, which no key is). +fn unreadable(error: &arboard::Error) -> bool { + !matches!( + error, + arboard::Error::ContentNotAvailable | arboard::Error::ConversionFailure + ) +} + +/// How long the hand-off read keeps trying, and how often. +const HANDOFF_WAIT: Duration = Duration::from_secs(2); +const HANDOFF_POLL: Duration = Duration::from_millis(100); + +/// `read` until it finds a key or a marker, or `budget` runs out. +/// `Unreadable` only when no read got through. +pub(super) fn retry_read(budget: Duration, poll: Duration, mut read: impl FnMut() -> Clip) -> Clip { + let deadline = Instant::now() + budget; + let mut readable = false; + loop { + match read() { + Clip::Unreadable => {} + Clip::Nothing => readable = true, + found => return found, + } + if Instant::now() + poll > deadline { + return if readable { + Clip::Nothing + } else { + Clip::Unreadable + }; + } + std::thread::sleep(poll); + } +} + /// Set to keep the system clipboard out of the CLI's own end-to-end tests. pub(super) const NO_CLIPBOARD_ENV: &str = "ELEVENLABS_ONBOARD_NO_CLIPBOARD"; @@ -80,16 +127,40 @@ impl Clipboard { } pub(super) fn read(&mut self) -> Clip { - classify(self.0.as_mut().and_then(|c| c.get_text().ok())) + match self.0.as_mut() { + Some(c) => clip_of(c.get_text()), + None => Clip::Nothing, + } + } + + /// What the approval page copied. On Windows another program (a + /// clipboard manager, a remote desktop) can hold the clipboard for a + /// moment, and a read gives up after a few milliseconds; the browser's + /// copy can also land late. So the read is tried again for a while. + pub(super) fn read_handoff(&mut self) -> Clip { + if self.0.is_none() { + return Clip::Nothing; + } + retry_read(HANDOFF_WAIT, HANDOFF_POLL, || self.read()) } /// Clear the clipboard if it still holds `value`; the user may have copied - /// something else since. + /// something else since. Tried again while the clipboard won't open, as + /// the hand-off read is. pub(super) fn clear_if(&mut self, value: &str) { - if let Some(c) = self.0.as_mut() { - if c.get_text().is_ok_and(|t| t.trim() == value) { - let _ = c.clear(); + let Some(c) = self.0.as_mut().filter(|_| !value.is_empty()) else { + return; + }; + let deadline = Instant::now() + HANDOFF_WAIT; + loop { + let done = match c.get_text() { + Ok(text) => text.trim() != value || c.clear().is_ok(), + Err(e) => !unreadable(&e), + }; + if done || Instant::now() + HANDOFF_POLL > deadline { + return; } + std::thread::sleep(HANDOFF_POLL); } } diff --git a/cli/elevenlabs/workflow/onboard/connect.rs b/cli/elevenlabs/workflow/onboard/connect.rs index a256212..e137446 100644 --- a/cli/elevenlabs/workflow/onboard/connect.rs +++ b/cli/elevenlabs/workflow/onboard/connect.rs @@ -224,6 +224,11 @@ pub(super) fn outcome_of( status: "no_key", why: "no key reached this command", }, + Clip::Unreadable => Outcome::Manual { + status: "clipboard_unreadable", + why: "this command couldn't read your clipboard. If it still holds the key \ + the approval page copied (it starts with sk_), skip step 1", + }, } } @@ -250,17 +255,17 @@ fn sign_in_and_store( }; on_signed_in(); let mut clipboard = Clipboard::open(); - let clip = clipboard.read(); + let clip = clipboard.read_handoff(); let copied = match &clip { Clip::Key(key) => key.clone(), Clip::Marker(m) => m.text(), - Clip::Nothing => String::new(), + Clip::Nothing | Clip::Unreadable => String::new(), }; let outcome = outcome_of(clip, |key| { auth::key_matches_sign_in(&client, &auth::api_base(ctx), &token, key) }); let reply = reply_for(project, &outcome); - // Whatever the outcome, and even if storing failed, a key never stays on the clipboard. + // Whatever the outcome, even if storing failed, a key read here never stays on the clipboard. clipboard.clear_if(&copied); reply } diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs index ac46752..8935889 100644 --- a/cli/elevenlabs/workflow/onboard/tests.rs +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -1,7 +1,7 @@ use super::super::util::opt_string; use super::auth::*; use super::browser::reachable_in; -use super::clipboard::{classify, Clip, Marker}; +use super::clipboard::{classify, clip_of, retry_read, Clip, Marker}; use super::env_file::{file_key, with_key, PLACEHOLDER_NOTE}; use super::onboard_command; use super::project::last4; @@ -51,6 +51,47 @@ fn the_clipboard_is_a_key_a_marker_or_nothing() { ); } assert!(matches!(classify(None), Clip::Nothing)); + assert_eq!(clip_of(Ok(format!("{key}\n"))), Clip::Key(key.into())); + assert_eq!( + clip_of(Err(arboard::Error::ContentNotAvailable)), + Clip::Nothing + ); + assert_eq!( + clip_of(Err(arboard::Error::ClipboardOccupied)), + Clip::Unreadable + ); +} + +#[test] +fn the_hand_off_read_waits_out_a_busy_or_late_clipboard() { + use std::time::Duration; + let key = "sk_0123456789abcdef0123456789abcdef"; + let run = |reads: Vec| { + let mut reads = reads.into_iter(); + let mut count = 0; + let clip = retry_read(Duration::from_millis(50), Duration::from_millis(1), || { + count += 1; + reads.next().unwrap_or(Clip::Unreadable) + }); + (clip, count) + }; + // Another program held the clipboard, then the key was there. + assert_eq!( + run(vec![ + Clip::Unreadable, + Clip::Unreadable, + Clip::Key(key.into()) + ]), + (Clip::Key(key.into()), 3) + ); + // The browser's copy landed late. + assert_eq!( + run(vec![Clip::Nothing, Clip::Marker(Marker::Failed)]), + (Clip::Marker(Marker::Failed), 2) + ); + // Never readable, versus read but empty. + assert_eq!(run(vec![]).0, Clip::Unreadable); + assert_eq!(run(vec![Clip::Nothing]).0, Clip::Nothing); } #[test] @@ -953,6 +994,13 @@ fn every_clipboard_outcome_has_its_reply() { .. } )); + assert!(matches!( + outcome_of(Clip::Unreadable, ok), + Outcome::Manual { + status: "clipboard_unreadable", + .. + } + )); let dir = tempfile::tempdir().unwrap(); git(dir.path(), &["init", "-q"]);