Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,8 +549,17 @@ elevenlabs-cli/1.7.1 os/macos install/npx
`os/` is `macos`, `linux`, `windows` or `other`; `env/`, when present, is `ssh`,
`container` or `wsl`; `install/` is read from where the binary sits (`npx`,
`bunx`, `pnpm-dlx`, `yarn-dlx`, `npm`, `homebrew`, `scoop`, `installer` or
`other`). Nothing else about the machine is sent: no paths, hostnames or ids.
`onboard` puts the same values on the sign-in's approval link.
`other`); `agent/`, when a coding agent ran the command, is `claude-code`,
`claude-desktop`, `cursor`, `codex`, `gemini-cli` or `vscode`, read from a
variable the agent sets. Nothing else about the machine is sent: no paths,
hostnames or ids. `onboard` puts the same values on the sign-in's approval link.

`onboard` also says how the project's last sign-in ended, when it ended in the
past day: `signin/<outcome>` on its requests, and `cli_prev` on the next
approval link, where `<outcome>` is a fixed word such as `timed-out`,
`stopped` or `no-key`. The link also carries `cli_prev_id`, the start of a
SHA-256 hash of that sign-in's `state`, so it can be matched to its approval
page; the state itself is never sent.

To turn it off, pass `--telemetry off` to any command, or set
`ELEVENLABS_TELEMETRY=off` (or `DO_NOT_TRACK=1`) to turn it off everywhere.
Expand Down
28 changes: 27 additions & 1 deletion cli/elevenlabs/workflow/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,19 @@ pub(super) fn suffix_env_key() -> String {
}

pub fn command_scope(command: &'static str) -> CommandScope {
command_scope_with(command, None)
}

/// [`command_scope`], with one more fixed tag after the command's (`onboard`
/// adds how the last sign-in ended). Built from fixed words by the caller,
/// like the command tag.
pub fn command_scope_with(command: &'static str, extra: Option<String>) -> CommandScope {
let key = suffix_env_key();
let previous = std::env::var(&key).ok();
let tag = format!("cmd/{command}");
let tag = match extra {
Some(extra) => format!("cmd/{command} {extra}"),
None => format!("cmd/{command}"),
};
let combined = match previous.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(existing) => format!("{existing} {tag}"),
None => tag,
Expand Down Expand Up @@ -629,6 +639,22 @@ mod tests {
});
}

#[test]
fn scope_puts_an_extra_tag_after_the_command() {
with_env(|key| {
std::env::set_var(key, "os/macos install/npx");
{
let _scope = command_scope_with("onboard.check", Some("signin/timed-out".into()));
let tag = std::env::var(key).unwrap();
assert_eq!(
tag,
"os/macos install/npx cmd/onboard.check signin/timed-out"
);
}
assert_eq!(std::env::var(key).as_deref(), Ok("os/macos install/npx"));
});
}

#[test]
fn scope_ignores_a_blank_existing_value() {
with_env(|key| {
Expand Down
237 changes: 212 additions & 25 deletions cli/elevenlabs/workflow/loopback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
//! - A callback for another sign-in (a tab left open by an earlier one) is
//! answered with the error page, and the wait goes on: an old tab can't end
//! the sign-in the newest one is for.
//! - A token endpoint that can't be reached is tried once more: the code never
//! got there, so it is still good.

use std::io::ErrorKind;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
Expand All @@ -32,6 +34,8 @@ use tokio::task::JoinSet;
const CALLBACK_PATH: &str = "/callback";
/// How long one connection may take to send its request.
const READ_TIMEOUT: Duration = Duration::from_secs(10);
/// How long to wait before trying an unreachable token endpoint again.
const EXCHANGE_RETRY_DELAY: Duration = Duration::from_secs(1);
/// What the error page says when the login failed after the callback; the
/// reason is in the worker's log.
const AFTER_CALLBACK_FAILED: &str =
Expand Down Expand Up @@ -100,10 +104,16 @@ pub(super) async fn sign_in(login: Login<'_>) -> Result<String, CliError> {
let _ = redirect(&mut browser, login.success_url).await;
Ok(token.access_token)
}
Err(e) => {
let page = error_page(login.error_url, "server_error", Some(AFTER_CALLBACK_FAILED));
Err(failure) => {
// Its own OAuth code, so the analytics can tell these apart.
let code = if failure.unreachable {
"temporarily_unavailable"
} else {
"server_error"
};
let page = error_page(login.error_url, code, Some(AFTER_CALLBACK_FAILED));
let _ = redirect(&mut browser, &page).await;
Err(e)
Err(failure.error)
}
}
}
Expand Down Expand Up @@ -361,39 +371,75 @@ fn challenge(verifier: &str) -> String {
.encode(sha2::Sha256::digest(verifier.as_bytes()))
}

/// Trade the code for a token, as `auth login` does.
/// Why a code couldn't be traded for a token.
struct ExchangeFailed {
error: CliError,
/// The token endpoint was never reached, so the code is still unused.
unreachable: bool,
}

/// Trade the code for a token, as `auth login` does. A token endpoint that
/// can't be reached is tried once more.
async fn exchange(
token_url: &str,
client_id: &str,
code: &str,
verifier: &str,
redirect_uri: &str,
) -> Result<TokenBundle, CliError> {
) -> Result<TokenBundle, ExchangeFailed> {
#[derive(Deserialize)]
struct Token {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
}
let failed = |e: String| CliError::Auth(format!("PKCE token exchange failed: {e}"));
let response = reqwest::Client::builder()
// `kind` names the failure for `onboard`'s report (`onboard::connect::sign_in_failure`).
let failed = |kind: &str, e: String| ExchangeFailed {
error: CliError::Auth(format!("PKCE token exchange failed [{kind}]: {e}")),
unreachable: kind == "connect",
};
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| failed(e.to_string()))?
.post(token_url)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("code_verifier", verifier),
("client_id", client_id),
("redirect_uri", redirect_uri),
])
.send()
.await
.map_err(|e| failed(e.to_string()))?;
.map_err(|e| failed("request", e.to_string()))?;
let send = || {
client
.post(token_url)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("code_verifier", verifier),
("client_id", client_id),
("redirect_uri", redirect_uri),
])
.send()
};
// Only a request that never connected is sent again: once the endpoint
// has the code, a second try could only be refused.
let response = match send().await {
Err(e) if e.is_connect() => {
tokio::time::sleep(EXCHANGE_RETRY_DELAY).await;
send().await
}
sent => sent,
}
.map_err(|e| {
// A connect that timed out never reached the endpoint either.
let kind = if e.is_connect() {
"connect"
} else if e.is_timeout() {
"timeout"
} else {
"request"
};
failed(kind, e.to_string())
})?;
let status = response.status();
let body = response.text().await.map_err(|e| failed(e.to_string()))?;
let body = response
.text()
.await
.map_err(|e| failed("response", e.to_string()))?;
if !status.is_success() {
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
Expand All @@ -403,10 +449,15 @@ async fn exchange(
.and_then(|d| d.as_str().map(str::to_string))
})
.unwrap_or_else(|| body.chars().take(200).collect());
return Err(failed(format!("HTTP {status}: {detail}")));
let kind = if status.is_server_error() {
"server"
} else {
"refused"
};
return Err(failed(kind, format!("HTTP {status}: {detail}")));
}
let token: Token =
serde_json::from_str(&body).map_err(|e| failed(format!("token response: {e}")))?;
let token: Token = serde_json::from_str(&body)
.map_err(|e| failed("response", format!("token response: {e}")))?;
Ok(TokenBundle::from_token_response(
&token.access_token,
token.refresh_token.as_deref(),
Expand All @@ -417,10 +468,10 @@ async fn exchange(
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::{Ipv6Addr, TcpListener, TcpStream};
use std::net::{Ipv4Addr, Ipv6Addr, TcpListener, TcpStream};
use std::time::Duration;

use super::{bind, classify, wait_for_callback, Callback};
use super::{bind, classify, exchange, wait_for_callback, Callback, EXCHANGE_RETRY_DELAY};

const ERROR_PAGE: &str = "https://example.test/error";

Expand Down Expand Up @@ -634,4 +685,140 @@ mod tests {
.to_string();
assert!(err.contains("Timed out waiting"), "{err}");
}

const CALLBACK: &str = "http://localhost:8484/callback";

/// What a stand-in token endpoint does with each request.
#[derive(Clone, Copy)]
enum Answer {
Token,
Refuse,
/// Read the request, then close without a response.
Hang,
}

/// Answers every connection to `listener` with `answer`; the count is of
/// connections that sent a request.
fn token_endpoint(
listener: tokio::net::TcpListener,
answer: Answer,
) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
use std::sync::atomic::Ordering;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let count = requests.clone();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let mut buf = [0u8; 4096];
if !matches!(socket.read(&mut buf).await, Ok(n) if n > 0) {
continue;
}
count.fetch_add(1, Ordering::SeqCst);
let (status, body) = match answer {
Answer::Token => ("200 OK", r#"{"access_token":"token","expires_in":3600}"#),
Answer::Refuse => ("400 Bad Request", r#"{"error":"invalid_grant"}"#),
Answer::Hang => continue,
};
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
}
});
requests
}

/// A port nothing listened on a moment ago. Another test may take it
/// before it's used, so the tests below try again with a new one.
fn closed_port() -> u16 {
TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.unwrap()
.local_addr()
.unwrap()
.port()
}

fn token_url(port: u16) -> String {
format!("http://127.0.0.1:{port}/v1/oauth/token")
}

#[tokio::test]
async fn a_token_endpoint_that_was_unreachable_gets_the_code_on_the_second_try() {
for _ in 0..5 {
let port = closed_port();
// The endpoint starts listening only after the first try was refused.
let up = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port))
.await
.ok()?;
Some(token_endpoint(listener, Answer::Token))
});
let started = std::time::Instant::now();
let token = exchange(&token_url(port), "client", "code", "verifier", CALLBACK).await;
let Some(requests) = up.await.unwrap() else {
continue; // another test took the port
};
assert!(
started.elapsed() >= EXCHANGE_RETRY_DELAY,
"it waited, then tried again"
);
assert_eq!(token.ok().map(|t| t.access_token).as_deref(), Some("token"));
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1);
return;
}
panic!("no port stayed free for the test");
}

#[tokio::test]
async fn a_token_endpoint_that_stays_unreachable_is_reported_as_unreachable() {
for _ in 0..5 {
let started = std::time::Instant::now();
let url = token_url(closed_port());
let Err(failure) = exchange(&url, "client", "code", "verifier", CALLBACK).await else {
continue; // another test took the port
};
if failure.unreachable {
assert!(started.elapsed() >= EXCHANGE_RETRY_DELAY, "it tried again");
// `onboard` reports it as `exchange-connect`.
assert!(
failure.error.to_string().contains("[connect]"),
"{}",
failure.error
);
return;
}
}
panic!("a closed port was never reported as unreachable");
}

/// Once the endpoint has the code, sending it again could only be refused,
/// whether the endpoint answered or not.
#[tokio::test]
async fn a_code_that_reached_the_endpoint_is_never_sent_again() {
for answer in [Answer::Refuse, Answer::Hang] {
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.unwrap();
let port = listener.local_addr().unwrap().port();
let requests = token_endpoint(listener, answer);
let Err(failure) =
exchange(&token_url(port), "client", "code", "verifier", CALLBACK).await
else {
panic!("the endpoint never handed out a token");
};
assert!(!failure.unreachable);
if let Answer::Refuse = answer {
assert!(
failure.error.to_string().contains("[refused]"),
"{}",
failure.error
);
}
// Time for a second try to arrive, if there were one.
tokio::time::sleep(EXCHANGE_RETRY_DELAY * 2).await;
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1);
}
}
}
Loading
Loading