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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ Auth resolution order for commands is:
3. `BRAINTRUST_PROFILE`
4. Org-based profile match (profile whose org matches `--org`/config org)
5. Single-profile auto-select (if only one profile exists)
6. Interactive profile picker (if multiple profiles exist and a TTY is available)

On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If a secure store is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions.

Expand Down
187 changes: 187 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,12 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result<ResolvedAuth> {
None
};

if let Some(profile_name) =
maybe_select_profile_for_auth(&auth_base, &store, &cfg_org, ui::can_prompt())?
{
auth_base.profile = Some(profile_name);
}

let mut auth = resolve_auth_from_store_with_secret_lookup(
&auth_base,
&store,
Expand Down Expand Up @@ -961,6 +967,113 @@ fn resolve_profile_for_org<'a>(org: &str, store: &'a AuthStore) -> Option<&'a st
}
}

fn profile_names_for_org<'a>(org: &str, store: &'a AuthStore) -> Vec<&'a str> {
store
.profiles
.iter()
.filter(|(_, profile)| profile.org_name.as_deref() == Some(org))
.map(|(name, _)| name.as_str())
.collect()
}

fn profile_label_from_store(name: &str, store: &AuthStore) -> String {
match store
.profiles
.get(name)
.and_then(|profile| profile.org_name.as_deref())
{
Some(org) if org != name => format!("{} (profile: {})", org, name),
_ => name.to_string(),
}
}

fn select_profile_from_store(
prompt: &str,
names: &[&str],
current: Option<&str>,
store: &AuthStore,
) -> Result<String> {
let labels: Vec<String> = names
.iter()
.map(|name| profile_label_from_store(name, store))
.collect();
let default = current
.and_then(|current| {
names.iter().position(|name| {
*name == current
|| store
.profiles
.get(*name)
.and_then(|profile| profile.org_name.as_deref())
== Some(current)
})
})
.unwrap_or(0);
let idx = ui::fuzzy_select(prompt, &labels, default)?;
Ok(names[idx].to_string())
}

fn maybe_select_profile_for_auth(
base: &BaseArgs,
store: &AuthStore,
cfg_org: &Option<String>,
can_prompt: bool,
) -> Result<Option<String>> {
if resolve_api_key_override(base).is_some() {
return Ok(None);
}

let requested_profile = base
.profile
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if requested_profile.is_some() {
return Ok(None);
}

let effective_org = base.org_name.as_deref().or(cfg_org.as_deref());
if let Some(org) = effective_org {
if resolve_profile_for_org(org, store).is_some() {
return Ok(None);
}

let matching_profiles = profile_names_for_org(org, store);
if matching_profiles.is_empty() {
return Ok(None);
}

if !can_prompt {
bail!(
"multiple profiles for org '{org}': {}. Use --profile to disambiguate.",
matching_profiles.join(", ")
);
}

return select_profile_from_store(
&format!("Multiple profiles for '{org}'. Select one"),
&matching_profiles,
Some(org),
store,
)
.map(Some);
}

if store.profiles.len() <= 1 {
return Ok(None);
}

let names: Vec<&str> = store.profiles.keys().map(|name| name.as_str()).collect();
if !can_prompt {
bail!(
"multiple auth profiles available: {}. Pass --profile <NAME>, set BRAINTRUST_PROFILE, or configure an org.",
names.join(", ")
);
}

select_profile_from_store("Select org", &names, None, store).map(Some)
}

fn resolve_auth_from_store_with_secret_lookup<F>(
base: &BaseArgs,
store: &AuthStore,
Expand Down Expand Up @@ -4022,6 +4135,80 @@ mod tests {
assert_eq!(resolve_profile_for_org("acme", &store), None);
}

#[test]
fn profile_selection_requires_choice_when_multiple_profiles_without_prompt() {
let base = make_base();
let mut store = AuthStore::default();
store.profiles.insert(
"alpha".into(),
AuthProfile {
org_name: Some("alpha-org".into()),
..Default::default()
},
);
store.profiles.insert(
"beta".into(),
AuthProfile {
org_name: Some("beta-org".into()),
..Default::default()
},
);

let err = maybe_select_profile_for_auth(&base, &store, &None, false)
.expect_err("selection should be required");

assert!(err.to_string().contains("multiple auth profiles available"));
assert!(err.to_string().contains("alpha"));
assert!(err.to_string().contains("beta"));
assert!(err.to_string().contains("--profile <NAME>"));
}

#[test]
fn profile_selection_requires_choice_for_ambiguous_org_without_prompt() {
let mut base = make_base();
base.org_name = Some("acme".into());

let mut store = AuthStore::default();
store.profiles.insert(
"work-1".into(),
AuthProfile {
org_name: Some("acme".into()),
..Default::default()
},
);
store.profiles.insert(
"work-2".into(),
AuthProfile {
org_name: Some("acme".into()),
..Default::default()
},
);

let err = maybe_select_profile_for_auth(&base, &store, &None, false)
.expect_err("org selection should be required");

assert!(err.to_string().contains("multiple profiles for org 'acme'"));
assert!(err.to_string().contains("work-1"));
assert!(err.to_string().contains("work-2"));
}

#[test]
fn profile_selection_skips_when_api_key_override_is_active() {
let mut base = make_base();
base.api_key = Some("explicit-key".into());

let mut store = AuthStore::default();
store
.profiles
.insert("alpha".into(), AuthProfile::default());
store.profiles.insert("beta".into(), AuthProfile::default());

let selection = maybe_select_profile_for_auth(&base, &store, &None, false)
.expect("api key override should skip profile selection");

assert_eq!(selection, None);
}

#[test]
fn resolve_auth_uses_org_to_find_profile() {
let mut base = make_base();
Expand Down
24 changes: 24 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,27 @@ fn setup_mcp_only_requires_auth_in_non_interactive_mode() {
"profile selection required in non-interactive mode",
));
}

#[test]
fn datasets_requires_profile_selection_when_multiple_profiles_exist() {
let repo = make_git_repo();
let home = tempfile::tempdir().expect("home tempdir");
let config_home = tempfile::tempdir().expect("config tempdir");
write_auth_store(
config_home.path(),
&[("alpha", "alpha-org"), ("beta", "beta-org")],
);

let mut cmd = bt_command();
clear_braintrust_auth_env(&mut cmd);
cmd.current_dir(repo.path())
.env("HOME", home.path())
.env("XDG_CONFIG_HOME", config_home.path())
.args(["datasets", "--no-input"])
.assert()
.failure()
.stderr(predicate::str::contains("multiple auth profiles available"))
.stderr(predicate::str::contains("--profile <NAME>"))
.stderr(predicate::str::contains("alpha"))
.stderr(predicate::str::contains("beta"));
}
Loading