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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,11 @@ Local transaction-id conversion helpers:

Auth resolution order for commands is:

1. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set)
2. `--profile` or `BRAINTRUST_PROFILE`
3. Org-based profile match (profile whose org matches `--org`/config org)
4. Single-profile auto-select (if only one profile exists)
1. Explicit `--profile`
2. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set)
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)

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
65 changes: 65 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::path::PathBuf;

use clap::Args;
Expand All @@ -22,6 +23,9 @@ pub struct BaseArgs {
#[arg(long, env = "BRAINTRUST_PROFILE", global = true)]
pub profile: Option<String>,

#[arg(skip = false)]
pub profile_explicit: bool,

/// Override active org (or via BRAINTRUST_ORG_NAME)
#[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true)]
pub org_name: Option<String>,
Expand Down Expand Up @@ -84,3 +88,64 @@ pub struct CLIArgs<T: Args> {
#[command(flatten)]
pub args: T,
}

pub fn has_explicit_profile_arg(args: &[OsString]) -> bool {
let mut idx = 1usize;
while idx < args.len() {
let Some(arg) = args[idx].to_str() else {
idx += 1;
continue;
};

if arg == "--" {
break;
}

if arg == "--profile" || arg.starts_with("--profile=") {
return true;
}

idx += 1;
}

false
}

#[cfg(test)]
mod tests {
use super::has_explicit_profile_arg;
use std::ffi::OsString;

#[test]
fn has_explicit_profile_arg_detects_split_flag() {
let args = vec![
OsString::from("bt"),
OsString::from("status"),
OsString::from("--profile"),
OsString::from("work"),
];
assert!(has_explicit_profile_arg(&args));
}

#[test]
fn has_explicit_profile_arg_detects_equals_flag() {
let args = vec![
OsString::from("bt"),
OsString::from("status"),
OsString::from("--profile=work"),
];
assert!(has_explicit_profile_arg(&args));
}

#[test]
fn has_explicit_profile_arg_ignores_passthrough_args() {
let args = vec![
OsString::from("bt"),
OsString::from("eval"),
OsString::from("--"),
OsString::from("--profile"),
OsString::from("work"),
];
assert!(!has_explicit_profile_arg(&args));
}
}
80 changes: 73 additions & 7 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,16 @@ fn maybe_warn_api_key_override(base: &BaseArgs) {
}
}

fn has_explicit_profile_selection(base: &BaseArgs) -> bool {
base.profile_explicit
&& base
.profile
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}

fn resolve_api_key_override(base: &BaseArgs) -> Option<String> {
if base.prefer_profile {
if base.prefer_profile || has_explicit_profile_selection(base) {
return None;
}
let value = base.api_key.as_deref()?.trim();
Expand Down Expand Up @@ -1209,12 +1217,6 @@ fn format_login_success(
}

async fn run_profiles(base: &BaseArgs, args: AuthProfilesArgs) -> Result<()> {
if resolve_api_key_override(base).is_some() {
println!("Auth source: --api-key/BRAINTRUST_API_KEY override");
eprintln!("Tip: pass --prefer-profile or unset BRAINTRUST_API_KEY to use profiles.");
return Ok(());
}

let store = load_auth_store()?;
if store.profiles.is_empty() {
println!("No saved profiles. Run `bt auth login` to create one.");
Expand Down Expand Up @@ -2645,6 +2647,7 @@ mod tests {
quiet: false,
no_color: false,
profile: None,
profile_explicit: false,
project: None,
org_name: None,
api_key: None,
Expand Down Expand Up @@ -2914,6 +2917,69 @@ mod tests {
assert_eq!(resolved.org_name.as_deref(), Some("Example Org"));
}

#[test]
fn resolve_auth_explicit_profile_ignores_api_key_override() {
let mut base = make_base();
base.api_key = Some("explicit-key".to_string());
base.profile = Some("work".to_string());
base.profile_explicit = true;

let mut store = AuthStore::default();
store.profiles.insert(
"work".to_string(),
AuthProfile {
auth_kind: AuthKind::ApiKey,
api_url: Some("https://api.example.com".to_string()),
app_url: None,
org_name: Some("Example Org".to_string()),
oauth_client_id: None,
oauth_access_expires_at: None,
..Default::default()
},
);

let resolved = resolve_auth_from_store_with_secret_lookup(
&base,
&store,
|_| Ok(Some("profile-key".to_string())),
&None,
)
.expect("resolve");
assert_eq!(resolved.api_key.as_deref(), Some("profile-key"));
assert_eq!(resolved.org_name.as_deref(), Some("Example Org"));
}

#[test]
fn resolve_auth_env_profile_still_prefers_api_key_override() {
let mut base = make_base();
base.api_key = Some("explicit-key".to_string());
base.profile = Some("work".to_string());

let mut store = AuthStore::default();
store.profiles.insert(
"work".to_string(),
AuthProfile {
auth_kind: AuthKind::ApiKey,
api_url: Some("https://api.example.com".to_string()),
app_url: None,
org_name: Some("Example Org".to_string()),
oauth_client_id: None,
oauth_access_expires_at: None,
..Default::default()
},
);

let resolved = resolve_auth_from_store_with_secret_lookup(
&base,
&store,
|_| Ok(Some("profile-key".to_string())),
&None,
)
.expect("resolve");
assert_eq!(resolved.api_key.as_deref(), Some("explicit-key"));
assert!(!resolved.is_oauth);
}

#[test]
fn resolve_auth_marks_oauth_profiles() {
let mut base = make_base();
Expand Down
1 change: 1 addition & 0 deletions src/functions/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3436,6 +3436,7 @@ mod tests {
quiet: false,
no_color: false,
profile: None,
profile_explicit: false,
org_name: None,
project: None,
api_key: None,
Expand Down
29 changes: 27 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ mod ui;
mod util_cmd;
mod utils;

use crate::args::{BaseArgs, CLIArgs};
use crate::args::{has_explicit_profile_arg, BaseArgs, CLIArgs};

const DEFAULT_CANARY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-canary.dev");
const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") {
Expand Down Expand Up @@ -178,6 +178,30 @@ impl Commands {
Commands::Status(cmd) => &cmd.base,
}
}

fn base_mut(&mut self) -> &mut BaseArgs {
match self {
Commands::Init(cmd) => &mut cmd.base,
Commands::Setup(cmd) => &mut cmd.base,
Commands::Docs(cmd) => &mut cmd.base,
Commands::Sql(cmd) => &mut cmd.base,
Commands::Auth(cmd) => &mut cmd.base,
Commands::View(cmd) => &mut cmd.base,
#[cfg(unix)]
Commands::Eval(cmd) => &mut cmd.base,
Commands::Projects(cmd) => &mut cmd.base,
Commands::Prompts(cmd) => &mut cmd.base,
Commands::SelfCommand(cmd) => &mut cmd.base,
Commands::Tools(cmd) => &mut cmd.base,
Commands::Scorers(cmd) => &mut cmd.base,
Commands::Functions(cmd) => &mut cmd.base,
Commands::Experiments(cmd) => &mut cmd.base,
Commands::Sync(cmd) => &mut cmd.base,
Commands::Util(cmd) => &mut cmd.base,
Commands::Switch(cmd) => &mut cmd.base,
Commands::Status(cmd) => &mut cmd.base,
}
}
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -207,7 +231,8 @@ async fn try_main() -> Result<()> {
let argv: Vec<OsString> = std::env::args_os().collect();
env::bootstrap_from_args(&argv)?;

let cli = Cli::parse_from(argv);
let mut cli = Cli::parse_from(argv.clone());
cli.command.base_mut().profile_explicit = has_explicit_profile_arg(&argv);
configure_output(cli.command.base());

match cli.command {
Expand Down
1 change: 1 addition & 0 deletions src/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ mod tests {
quiet: false,
no_color: false,
profile: None,
profile_explicit: false,
org_name: org.map(String::from),
project: project.map(String::from),
api_key: None,
Expand Down
1 change: 1 addition & 0 deletions src/traces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6061,6 +6061,7 @@ mod tests {
quiet: false,
no_color: false,
profile: None,
profile_explicit: false,
org_name: None,
project: None,
api_key: None,
Expand Down
53 changes: 53 additions & 0 deletions tests/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,22 @@ fn sanitized_env_keys() -> &'static [&'static str] {
]
}

fn auth_profiles_command(cwd: &Path, config_dir: &Path) -> Command {
let mut cmd = Command::new(bt_binary_path());
cmd.arg("auth")
.arg("profiles")
.current_dir(cwd)
.env("XDG_CONFIG_HOME", config_dir)
.env("APPDATA", config_dir)
.env("BRAINTRUST_NO_COLOR", "1")
.env_remove("BRAINTRUST_PROFILE")
.env_remove("BRAINTRUST_ORG_NAME")
.env_remove("BRAINTRUST_API_URL")
.env_remove("BRAINTRUST_APP_URL")
.env_remove("BRAINTRUST_ENV_FILE");
cmd
}

#[derive(Debug, Clone)]
struct MockProject {
id: String,
Expand Down Expand Up @@ -679,6 +695,43 @@ fn functions_help_lists_push_and_pull() {
assert!(stdout.contains("pull"));
}

#[test]
fn auth_profiles_ignores_api_key_env_override() {
let cwd = tempdir().expect("create temp cwd");
let config_dir = tempdir().expect("create temp config dir");

let output = auth_profiles_command(cwd.path(), config_dir.path())
.env("BRAINTRUST_API_KEY", "test-key")
.output()
.expect("run bt auth profiles with api key env");

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stdout.contains("No saved profiles. Run `bt auth login` to create one."));
assert!(!stdout.contains("Auth source: --api-key/BRAINTRUST_API_KEY override"));
assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY"));
}

#[test]
fn auth_profiles_ignores_api_key_from_dotenv() {
let cwd = tempdir().expect("create temp cwd");
let config_dir = tempdir().expect("create temp config dir");
fs::write(cwd.path().join(".env"), "BRAINTRUST_API_KEY=test-key\n").expect("write .env");

let output = auth_profiles_command(cwd.path(), config_dir.path())
.env_remove("BRAINTRUST_API_KEY")
.output()
.expect("run bt auth profiles with dotenv api key");

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stdout.contains("No saved profiles. Run `bt auth login` to create one."));
assert!(!stdout.contains("Auth source: --api-key/BRAINTRUST_API_KEY override"));
assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY"));
}

#[test]
fn push_and_pull_help_are_machine_readable() {
let push_help = Command::new(bt_binary_path())
Expand Down
Loading