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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/cortex-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,4 @@ chrono = { workspace = true }

[dev-dependencies]
serial_test = { workspace = true }
wiremock = { workspace = true }
91 changes: 37 additions & 54 deletions src/cortex-cli/src/cli/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::login::{
read_api_key_from_stdin, run_login_status, run_login_with_api_key, run_login_with_device_code,
run_logout,
};
use crate::styled_output::{print_success, print_warning};
use crate::styled_output::print_success;

/// Dispatch a CLI command to its handler.
///
Expand Down Expand Up @@ -561,65 +561,48 @@ fn install_completions(shell: Shell) -> Result<()> {
// Command handler stubs (implemented elsewhere)
// ============================================================================

/// Show current logged-in user.
/// Show current logged-in user via live `GET /v1/me` on the configured origin.
pub async fn run_whoami() -> Result<()> {
use cortex_login::{AuthMode, load_auth_with_fallback, safe_format_key};

let cortex_home = dirs::home_dir()
.map(|h| h.join(".cortex"))
.unwrap_or_else(|| std::path::PathBuf::from(".cortex"));

// Check environment variables first
if let Ok(token) = std::env::var("CORTEX_AUTH_TOKEN")
&& !token.is_empty()
{
println!(
"Authenticated via CORTEX_AUTH_TOKEN: {}",
safe_format_key(&token)
);
return Ok(());
}

if let Ok(token) = std::env::var("CORTEX_API_KEY")
&& !token.is_empty()
{
println!(
"Authenticated via CORTEX_API_KEY: {}",
safe_format_key(&token)
);
return Ok(());
}
use cortex_engine::client::{AUTH_REQUIRED, CodeAgentClient};
use cortex_login::load_auth_with_fallback;

let cortex_home = crate::utils::paths::get_cortex_home();

let token = std::env::var("CORTEX_AUTH_TOKEN")
.ok()
.filter(|token| !token.is_empty())
.or_else(|| {
std::env::var("CORTEX_API_KEY")
.ok()
.filter(|token| !token.is_empty())
})
.or_else(|| {
load_auth_with_fallback(&cortex_home)
.ok()
.flatten()
.and_then(|auth| auth.get_token().map(str::to_string))
});

let Some(token) = token else {
bail!("{AUTH_REQUIRED}");
};

// Load stored credentials
match load_auth_with_fallback(&cortex_home) {
Ok(Some(auth)) => match auth.mode {
AuthMode::ApiKey => {
if let Some(key) = auth.get_token() {
println!("Logged in with API key: {}", safe_format_key(key));
} else {
println!("Logged in with API key (stored)");
}
let client = CodeAgentClient::new(None, Some(token));
match client.fetch_me().await {
Ok(me) => {
match (&me.name, &me.email) {
(Some(name), Some(email)) => println!("Logged in as {name} <{email}>"),
(Some(name), None) => println!("Logged in as {name}"),
(None, Some(email)) => println!("Logged in as {email}"),
(None, None) => println!("Logged in"),
}
AuthMode::OAuth => {
if let Some(account_id) = &auth.account_id {
println!("Logged in via OAuth (account: {})", account_id);
} else {
println!("Logged in via OAuth");
}
if auth.is_expired() {
print_warning("Token is expired. Run 'cortex login' to refresh.");
}
if let Some(org) = &me.org_name {
println!("Organization: {org}");
}
},
Ok(None) => {
println!("Not logged in. Run 'cortex login' to authenticate.");
}
Err(e) => {
return Err(anyhow::anyhow!("Error checking login status: {}", e));
Ok(())
}
Err(e) => bail!("{}", e.user_friendly_message()),
}

Ok(())
}

#[path = "ux_sessions.rs"]
Expand Down
190 changes: 190 additions & 0 deletions src/cortex-cli/tests/whoami_me.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//! `cortex whoami` talks to `GET /v1/me` on the configured API origin.
//!
//! These tests prove a staging/loopback `CORTEX_API_URL` is the only host
//! contacted. Nothing here reaches `api.cortex.foundation`.

use std::process::Command;

fn whoami(home: &std::path::Path, api_url: &str, token: Option<&str>) -> std::process::Output {
whoami_with_homes(home, home, api_url, token)
}

fn whoami_with_homes(
home: &std::path::Path,
cortex_home: &std::path::Path,
api_url: &str,
token: Option<&str>,
) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_Cortex"));
command
.arg("whoami")
.env("HOME", home)
.env("CORTEX_HOME", cortex_home)
.env("CORTEX_API_URL", api_url)
.env("RUST_LOG", "off")
.env("NO_COLOR", "1")
.env_remove("CORTEX_API_KEY")
.current_dir(cortex_home);
match token {
Some(token) => {
command.env("CORTEX_AUTH_TOKEN", token);
}
None => {
command.env_remove("CORTEX_AUTH_TOKEN");
}
Comment thread
echobt marked this conversation as resolved.
}
command.output().unwrap()
}

fn combined(output: &std::process::Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
}

#[tokio::test(flavor = "multi_thread")]
async fn whoami_hits_v1_me_on_configured_origin_never_production() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v1/me"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "Ada Lovelace",
"email": "ada@example.com",
"organizations": [{ "org_name": "Analytical Engines" }]
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/auth/me"))
.respond_with(wiremock::ResponseTemplate::new(599))
.expect(0)
.mount(&server)
.await;

let home = tempfile::tempdir().unwrap();
let output = whoami(home.path(), &server.uri(), Some("staging-bearer"));
assert!(
output.status.success(),
"whoami against a 200 fixture must succeed: {}",
combined(&output)
);
let text = combined(&output);
assert!(
text.contains("Ada Lovelace"),
"live /v1/me identity should be printed: {text}"
);
assert!(
!text.contains("api.cortex.foundation"),
"production host must not appear: {text}"
);

let requests = server.received_requests().await.expect("recorded requests");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), "/v1/me");
let request_url = requests[0].url.to_string();
assert!(
!request_url.contains("api.cortex.foundation"),
"production host must not be contacted: {request_url}"
);
assert!(
request_url.contains("127.0.0.1") || request_url.contains("localhost"),
"request must stay on the loopback fixture: {request_url}"
);
}

#[tokio::test(flavor = "multi_thread")]
async fn whoami_401_prints_cortex_login_and_exits_nonzero() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v1/me"))
.respond_with(wiremock::ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;

let home = tempfile::tempdir().unwrap();
let output = whoami(home.path(), &server.uri(), Some("revoked-token"));
assert!(
!output.status.success(),
"a revoked token must fail whoami; got {}: {}",
output.status,
combined(&output)
);
let text = combined(&output);
assert!(
text.contains("cortex login"),
"401 must print the login recovery copy: {text}"
);
assert!(!text.to_lowercase().contains("reqwest"), "{text}");
assert!(!text.contains("api.cortex.foundation"), "{text}");

let requests = server.received_requests().await.expect("recorded requests");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), "/v1/me");
}

#[tokio::test(flavor = "multi_thread")]
async fn whoami_isolated_cortex_home_does_not_use_default_profile_token() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v1/me"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "Default Profile"
})),
)
.expect(0)
.mount(&server)
.await;

let default_home = tempfile::tempdir().unwrap();
let isolated = tempfile::tempdir().unwrap();
let default_cortex = default_home.path().join(".cortex");
std::fs::create_dir_all(&default_cortex).unwrap();
std::fs::write(
default_cortex.join("auth.json"),
r#"{"mode":"ApiKey","api_key":"home-profile-bearer"}"#,
)
.unwrap();

let output = whoami_with_homes(default_home.path(), isolated.path(), &server.uri(), None);
assert!(
!output.status.success(),
"empty CORTEX_HOME must not inherit $HOME/.cortex: {}",
combined(&output)
);
let text = combined(&output);
assert!(
text.contains("cortex login") || text.contains("CORTEX_API_KEY"),
"{text}"
);
assert!(!text.contains("Default Profile"), "{text}");
let requests = server.received_requests().await.expect("recorded requests");
assert!(
requests.is_empty(),
"default-profile bearer must not reach /v1/me: {}",
requests.len()
);
}

#[test]
fn whoami_without_credentials_prints_login_copy_and_does_not_need_the_network() {
let home = tempfile::tempdir().unwrap();
let output = whoami(home.path(), "http://127.0.0.1:1", None);
assert!(
!output.status.success(),
"no credential is not a successful whoami: {}",
combined(&output)
);
let text = combined(&output);
assert!(
text.contains("cortex login") || text.contains("CORTEX_API_KEY"),
"{text}"
);
assert!(!text.contains("api.cortex.foundation"), "{text}");
}
1 change: 1 addition & 0 deletions src/cortex-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,4 @@ cortex-sandbox = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
serial_test = { workspace = true }
wiremock = { workspace = true }
4 changes: 2 additions & 2 deletions src/cortex-engine/src/client/code_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ impl CodeAgentClient {
.to_string()
}

async fn authed_get(&self, url: &str) -> Result<reqwest::Response> {
pub(super) async fn authed_get(&self, url: &str) -> Result<reqwest::Response> {
let mut req = self
.http
.get(url)
Expand Down Expand Up @@ -786,7 +786,7 @@ fn apply_auth(mut req: reqwest::RequestBuilder, auth: Option<&str>) -> reqwest::
req
}

async fn parse_json<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
pub(super) async fn parse_json<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
resp.json().await.map_err(|e| CortexError::BackendError {
message: format!("Failed to parse API response: {e}"),
})
Expand Down
Loading
Loading