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/loopback.rs b/cli/elevenlabs/workflow/loopback.rs index 8074971..046d014 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,29 @@ 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. 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)); + 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/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 aefa960..98421de 100644 --- a/cli/elevenlabs/workflow/onboard/auth.rs +++ b/cli/elevenlabs/workflow/onboard/auth.rs @@ -5,12 +5,12 @@ 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; 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; @@ -86,12 +86,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 @@ -99,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, @@ -110,24 +111,32 @@ 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), })) } -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) +/// 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. +/// 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 +144,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/browser.rs b/cli/elevenlabs/workflow/onboard/browser.rs index 50e4f0e..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 std::path::Path; +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,25 +10,19 @@ 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 { - 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/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 ecd043d..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", + }, } } @@ -235,30 +240,32 @@ 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(); + 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), 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 02ddde3..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"]); @@ -1135,3 +1183,41 @@ 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()); +} + +#[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..ff8a4f6 --- /dev/null +++ b/cli/elevenlabs/workflow/platform.rs @@ -0,0 +1,112 @@ +//! 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>) { + // 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, + ) +} + +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)); + } +} diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs index 4ce9189..75b5707 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)); @@ -1166,6 +1169,12 @@ 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. + 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}" @@ -1277,3 +1286,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}"); +}