Skip to content
Merged
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
18 changes: 11 additions & 7 deletions cli/elevenlabs/workflow/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ fn advance_cursor(resp: &Value, seen: &mut std::collections::HashSet<String>) ->
/// 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
Expand All @@ -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,
Expand Down Expand Up @@ -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());
Expand All @@ -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"));
Expand All @@ -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}");
});
}

Expand Down
40 changes: 24 additions & 16 deletions cli/elevenlabs/workflow/loopback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CliError> {
let verifier = random_token();
let state = random_token();
let (listeners, port) = bind(login.redirect_ports).await?;
Expand All @@ -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));
Expand Down
1 change: 1 addition & 0 deletions cli/elevenlabs/workflow/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod flows_wait;
mod intent;
mod loopback;
mod onboard;
mod platform;
mod project;
mod residency;
mod say;
Expand Down
30 changes: 19 additions & 11 deletions cli/elevenlabs/workflow/onboard/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -86,19 +86,20 @@ fn parse_sign_in(main_rs: &str) -> Result<SignIn, String> {
})
}

/// 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<String, CliError> {
let config = sign_in_config()?;
// A local --base-url also moves the token endpoint, for testing against a local backend.
let oauth_base = ctx
.base_url_override()
.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,
Expand All @@ -110,32 +111,39 @@ 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: &params,
no_browser,
timeout: Duration::from_secs(SIGN_IN_MINUTES * 60),
}))
}

pub(super) fn stored_access_token() -> Option<String> {
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<bool, CliError> {
let unknown = || {
CliError::Other(anyhow::anyhow!(
"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))
Expand Down
22 changes: 8 additions & 14 deletions cli/elevenlabs/workflow/onboard/browser.rs
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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(),
)
}
83 changes: 77 additions & 6 deletions cli/elevenlabs/workflow/onboard/clipboard.rs
Original file line number Diff line number Diff line change
@@ -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:<status>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Marker {
Expand Down Expand Up @@ -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<String>) -> Clip {
Expand Down Expand Up @@ -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<String, arboard::Error>) -> 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";

Expand All @@ -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);
}
}

Expand Down
Loading
Loading