diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index c66affd5..ca01b55f 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use tower_api::apis::configuration::Configuration; +use tower_api::apis::configuration::{ApiKey, Configuration}; use url::Url; mod error; @@ -22,6 +22,9 @@ pub struct Config { #[serde(skip_serializing, skip_deserializing)] pub session: Option, + #[serde(skip_serializing, skip_deserializing)] + pub api_key: Option, + // cache_dir is the directory that we should cache uv artifacts within. pub cache_dir: Option, } @@ -33,6 +36,7 @@ impl Config { tower_url: default_tower_url(), json: false, session: None, + api_key: None, cache_dir: Some(default_cache_dir()), } } @@ -44,12 +48,14 @@ impl Config { } else { default_tower_url() }; + let api_key = std::env::var("TOWER_API_KEY").ok(); Self { debug, tower_url, json: false, session: None, + api_key, cache_dir: Some(default_cache_dir()), } } @@ -78,6 +84,7 @@ impl Config { tower_url: sess.tower_url.clone(), json: self.json, session: Some(sess), + api_key: self.api_key, cache_dir: Some(default_cache_dir()), } } @@ -181,6 +188,15 @@ impl Config { configuration.base_path = base_path.to_string(); + // API key takes precedence over session-based auth + if let Some(ref key) = self.api_key { + configuration.api_key = Some(ApiKey { + prefix: None, + key: key.clone(), + }); + return configuration; + } + // Always read from disk to pick up team switches if let Ok(session) = Session::from_config_dir() { if let Some(active_team) = &session.active_team { diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index f4b0a3c0..d2e288cb 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -357,6 +357,22 @@ pub async fn refresh_session( .await } +pub async fn list_teams( + config: &Config, +) -> Result< + tower_api::models::ListTeamsResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::ListTeamsParams { + page: None, + page_size: None, + }; + + unwrap_api_response(tower_api::apis::default_api::list_teams(api_config, params)).await +} + pub enum LogStreamEvent { EventLog(tower_api::models::RunLogLine), EventWarning(tower_api::models::EventWarning), @@ -614,6 +630,17 @@ impl ResponseEntity for tower_api::apis::default_api::DescribeSecretsKeySuccess } } +impl ResponseEntity for tower_api::apis::default_api::ListTeamsSuccess { + type Data = tower_api::models::ListTeamsResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + impl ResponseEntity for tower_api::apis::default_api::ListAppsSuccess { type Data = tower_api::models::ListAppsResponse; diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index 43de9c31..3af7c0be 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -30,10 +30,12 @@ impl App { pub fn new() -> Self { let cmd = root_cmd(); - // In certain scenarios, we want to load a session from a JWT token supplied as an - // environment variable. This is for programmatic use cases where we want to test the CLI - // in automated environments, for instance. - let session = if let Ok(token) = std::env::var("TOWER_JWT") { + // When TOWER_API_KEY is set, skip session entirely — the API key is self-contained + // and authenticates via X-API-Key header rather than Bearer JWT. + let session = if std::env::var("TOWER_API_KEY").is_ok() { + None + } else if let Ok(token) = std::env::var("TOWER_JWT") { + // Load session from a JWT token for programmatic use cases Session::from_jwt(&token).ok() } else { Session::from_config_dir().ok() diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index f41d9dc6..5ea1038b 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -334,6 +334,23 @@ impl TowerService { }) } + async fn list_teams_via_api(&self) -> Result { + let response = api::list_teams(&self.config).await.map_err(|e| { + McpError::internal_error( + "Failed to list teams", + Some(json!({"error": e.to_string()})), + ) + })?; + + let teams: Vec = response + .teams + .into_iter() + .map(|team| json!({"name": team.name})) + .collect(); + + Self::json_success(json!({"teams": teams})) + } + fn extract_api_error_message(error: &crate::Error) -> String { let crate::Error::ApiRunError { source } = error else { return error.to_string(); @@ -640,6 +657,10 @@ impl TowerService { #[tool(description = "List teams you belong to")] async fn tower_teams_list(&self) -> Result { + if self.config.api_key.is_some() { + return self.list_teams_via_api().await; + } + let response = api::refresh_session(&self.config).await.map_err(|e| { McpError::internal_error( "Failed to refresh session", diff --git a/crates/tower-cmd/src/session.rs b/crates/tower-cmd/src/session.rs index 8f0f8f24..65a6967a 100644 --- a/crates/tower-cmd/src/session.rs +++ b/crates/tower-cmd/src/session.rs @@ -1,5 +1,6 @@ use crate::output; use clap::{Arg, ArgMatches, Command}; +use colored::Colorize; use config::{Config, Session}; use tokio::{time, time::Duration}; use tower_api::models::CreateDeviceLoginTicketResponse; @@ -22,6 +23,22 @@ pub fn login_cmd() -> Command { pub async fn do_login(config: Config, args: &ArgMatches) { output::banner(); + if std::env::var("TOWER_API_KEY").is_ok() { + output::write(&format!( + "{} TOWER_API_KEY is set. As long as this environment variable is present, \ + the CLI will authenticate using the API key and ignore the session \ + created by this login flow.\n", + "Warning:".yellow(), + )); + + eprint!("Do you want to continue? [y/N] "); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() || !input.trim().eq_ignore_ascii_case("y") + { + return; + } + } + // Open a browser by default, unless the --no-browser flag is set. let open_browser = !args.get_flag("no-browser"); diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs index 1a5a9174..a667baf4 100644 --- a/crates/tower-cmd/src/teams.rs +++ b/crates/tower-cmd/src/teams.rs @@ -48,8 +48,35 @@ async fn refresh_session(config: &Config) -> config::Session { } pub async fn do_list(config: Config) { + if config.api_key.is_some() { + do_list_via_api(&config).await; + } else { + do_list_via_session(&config).await; + } +} + +async fn do_list_via_api(config: &Config) { + let resp = output::with_spinner("Fetching teams", api::list_teams(config)).await; + + let headers = vec!["Name"] + .into_iter() + .map(|h| h.yellow().to_string()) + .collect(); + + let teams_data: Vec> = resp + .teams + .iter() + .map(|team| vec![team.name.clone()]) + .collect(); + + output::newline(); + output::table(headers, teams_data, None::<&Vec>); + output::newline(); +} + +async fn do_list_via_session(config: &Config) { // Refresh the session and get the updated data - let session = refresh_session(&config).await; + let session = refresh_session(config).await; // Get the current active team from the session let active_team = session.active_team.clone(); diff --git a/tests/integration/features/cli_api_key_auth.feature b/tests/integration/features/cli_api_key_auth.feature new file mode 100644 index 00000000..c559c819 --- /dev/null +++ b/tests/integration/features/cli_api_key_auth.feature @@ -0,0 +1,18 @@ +Feature: API Key Authentication + As a user authenticating with an API key + I want the CLI to use the X-API-Key header + So that I can interact with Tower without a session + + Scenario: List apps using API key auth + When I run "tower --json apps list" via CLI with API key + Then the output should be valid JSON + And no session.json should exist in the temp home + + Scenario: List teams using API key auth + When I run "tower teams list" via CLI with API key + Then no session.json should exist in the temp home + + Scenario: Login warns when API key is set + When I run "tower login --no-browser" via CLI with API key + Then the output should show "TOWER_API_KEY is set" + And the output should show "ignore the session" diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index e0b71da6..92d421a4 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -57,6 +57,54 @@ def step_run_cli_command(context, command): raise +@step('I run "{command}" via CLI with API key') +def step_run_cli_command_with_api_key(context, command): + """Run a Tower CLI command authenticating via TOWER_API_KEY instead of session.json""" + cli_path = context.tower_binary + + cmd_parts = shlex.split(command) + full_command = [cli_path] + cmd_parts[1:] # Skip 'tower' prefix + + try: + test_env = os.environ.copy() + test_env["FORCE_COLOR"] = "1" + test_env["CLICOLOR_FORCE"] = "1" + test_env["TOWER_URL"] = context.tower_url + test_env["TOWER_API_KEY"] = "sk-test-api-key" + + # Use a temp HOME with no session.json to prove API key auth works standalone + test_env["HOME"] = context.temp_dir + + result = subprocess.run( + full_command, + capture_output=True, + text=True, + timeout=60, + env=test_env, + ) + context.cli_output = result.stdout + result.stderr + context.cli_stdout = result.stdout + context.cli_return_code = result.returncode + except subprocess.TimeoutExpired: + context.cli_output = "Command timed out" + context.cli_stdout = "" + context.cli_return_code = 124 + except Exception as e: + print(f"DEBUG: Exception in CLI command: {type(e).__name__}: {e}") + print(f"DEBUG: Command was: {full_command}") + print(f"DEBUG: Working directory: {os.getcwd()}") + raise + + +@step("no session.json should exist in the temp home") +def step_no_session_json(context): + """Verify that API key auth did not create a session.json file""" + session_path = Path(context.temp_dir) / ".config" / "tower" / "session.json" + assert ( + not session_path.exists() + ), f"session.json should not exist but found at {session_path}" + + @step('I run "{command}" via CLI using created app name') def step_run_cli_command_with_app_name(context, command): """Run a Tower CLI command with the generated app name injected."""