diff --git a/Cargo.lock b/Cargo.lock index 03786f32..690d6a6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1067,6 +1067,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "wiremock", ] [[package]] @@ -1220,6 +1221,7 @@ dependencies = [ "uuid", "walkdir", "which", + "wiremock", "zeroize", ] diff --git a/src/cortex-cli/Cargo.toml b/src/cortex-cli/Cargo.toml index 59da19f8..0f36dc50 100644 --- a/src/cortex-cli/Cargo.toml +++ b/src/cortex-cli/Cargo.toml @@ -105,3 +105,4 @@ chrono = { workspace = true } [dev-dependencies] serial_test = { workspace = true } +wiremock = { workspace = true } diff --git a/src/cortex-cli/src/cli/handlers.rs b/src/cortex-cli/src/cli/handlers.rs index a0636c25..ea9efbcd 100644 --- a/src/cortex-cli/src/cli/handlers.rs +++ b/src/cortex-cli/src/cli/handlers.rs @@ -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. /// @@ -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"] diff --git a/src/cortex-cli/tests/whoami_me.rs b/src/cortex-cli/tests/whoami_me.rs new file mode 100644 index 00000000..1aaac8e8 --- /dev/null +++ b/src/cortex-cli/tests/whoami_me.rs @@ -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"); + } + } + 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}"); +} diff --git a/src/cortex-engine/Cargo.toml b/src/cortex-engine/Cargo.toml index f045748b..6530de7d 100644 --- a/src/cortex-engine/Cargo.toml +++ b/src/cortex-engine/Cargo.toml @@ -129,3 +129,4 @@ cortex-sandbox = { workspace = true } [dev-dependencies] tempfile = { workspace = true } serial_test = { workspace = true } +wiremock = { workspace = true } diff --git a/src/cortex-engine/src/client/code_agent.rs b/src/cortex-engine/src/client/code_agent.rs index 90444d85..2260d9f8 100644 --- a/src/cortex-engine/src/client/code_agent.rs +++ b/src/cortex-engine/src/client/code_agent.rs @@ -659,7 +659,7 @@ impl CodeAgentClient { .to_string() } - async fn authed_get(&self, url: &str) -> Result { + pub(super) async fn authed_get(&self, url: &str) -> Result { let mut req = self .http .get(url) @@ -786,7 +786,7 @@ fn apply_auth(mut req: reqwest::RequestBuilder, auth: Option<&str>) -> reqwest:: req } -async fn parse_json Deserialize<'de>>(resp: reqwest::Response) -> Result { +pub(super) async fn parse_json Deserialize<'de>>(resp: reqwest::Response) -> Result { resp.json().await.map_err(|e| CortexError::BackendError { message: format!("Failed to parse API response: {e}"), }) diff --git a/src/cortex-engine/src/client/me.rs b/src/cortex-engine/src/client/me.rs new file mode 100644 index 00000000..4db53fae --- /dev/null +++ b/src/cortex-engine/src/client/me.rs @@ -0,0 +1,257 @@ +//! `GET /v1/me` on the configured API origin (`CORTEX_API_URL`). + +use std::time::Duration; + +use tokio::time::timeout; + +use super::code_agent::{AUTH_REQUIRED, CodeAgentClient, normalize_api_base, parse_json}; +use crate::error::{CortexError, Result}; + +/// Documented identity route on the configured API origin. +pub const ME_PATH: &str = "/v1/me"; + +/// Cap for `GET /v1/me` so identity never freezes the TUI render path. +pub const ME_FETCH_TIMEOUT: Duration = Duration::from_secs(3); + +/// Build `GET {base}/v1/me` from the same origin resolver as device login and turns. +pub fn me_url(base_url: &str) -> String { + format!("{}{ME_PATH}", normalize_api_base(base_url)) +} + +/// Identity returned by `GET /v1/me` on the configured API origin. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MeProfile { + pub name: Option, + pub email: Option, + pub org_name: Option, +} + +impl MeProfile { + /// Parse the live `/v1/me` JSON (and a few stable aliases). + pub fn from_json(value: &serde_json::Value) -> Self { + let name = first_nonempty_str(value, &["name", "display_name"]).or_else(|| { + value + .get("user") + .and_then(|user| first_nonempty_str(user, &["name", "display_name"])) + }); + let email = first_nonempty_str(value, &["email"]).or_else(|| { + value + .get("user") + .and_then(|user| first_nonempty_str(user, &["email"])) + }); + let org_name = value + .get("organizations") + .and_then(|v| v.as_array()) + .and_then(|orgs| orgs.first()) + .and_then(|org| first_nonempty_str(org, &["org_name", "name"])) + .or_else(|| first_nonempty_str(value, &["org_name", "organization"])); + Self { + name, + email, + org_name, + } + } +} + +fn first_nonempty_str(value: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value.get(*key).and_then(|v| v.as_str()).and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + }) +} + +impl CodeAgentClient { + /// `GET {base}/v1/me` on this client's configured origin, capped at 3s. + /// + /// Does not start a guest session. A missing token is an auth error. + pub async fn fetch_me(&self) -> Result { + let token = self.auth_token().await; + if token.as_ref().is_none_or(|t| t.is_empty()) { + return Err(CortexError::AuthenticationError { + message: AUTH_REQUIRED.to_string(), + }); + } + let url = me_url(self.base_url()); + let json = match timeout(ME_FETCH_TIMEOUT, async { + let resp = self.authed_get(&url).await?; + parse_json::(resp).await + }) + .await + { + Ok(result) => result?, + Err(_) => return Err(CortexError::Timeout), + }; + Ok(MeProfile::from_json(&json)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn me_url_is_v1_me_on_the_given_origin() { + assert_eq!( + me_url("https://api.cortex.foundation/"), + "https://api.cortex.foundation/v1/me" + ); + assert_eq!( + me_url("http://127.0.0.1:18081"), + "http://127.0.0.1:18081/v1/me" + ); + assert!(!me_url("http://127.0.0.1:18081").contains("api.cortex.foundation")); + assert!(!me_url("http://127.0.0.1:18081").contains("/auth/me")); + } + + #[test] + fn me_profile_parses_name_email_and_org() { + let json = serde_json::json!({ + "name": "Ada Lovelace", + "email": "ada@example.com", + "organizations": [{ "org_name": "Analytical Engines" }] + }); + let profile = MeProfile::from_json(&json); + assert_eq!(profile.name.as_deref(), Some("Ada Lovelace")); + assert_eq!(profile.email.as_deref(), Some("ada@example.com")); + assert_eq!(profile.org_name.as_deref(), Some("Analytical Engines")); + } + + #[test] + #[serial_test::serial] + fn client_origin_follows_cortex_api_url() { + let previous = std::env::var("CORTEX_API_URL").ok(); + unsafe { + std::env::set_var("CORTEX_API_URL", "http://127.0.0.1:18081/"); + } + let client = CodeAgentClient::new(None, Some("staging-bearer".into())); + assert_eq!(client.base_url(), "http://127.0.0.1:18081"); + assert_eq!(me_url(client.base_url()), "http://127.0.0.1:18081/v1/me"); + assert!( + !me_url(client.base_url()).contains("api.cortex.foundation"), + "configured origin must not fall back to production" + ); + match previous { + Some(value) => unsafe { std::env::set_var("CORTEX_API_URL", value) }, + None => unsafe { std::env::remove_var("CORTEX_API_URL") }, + } + } + + #[tokio::test] + async fn fetch_me_hits_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 client = CodeAgentClient::new(Some(server.uri()), Some("staging-bearer".into())); + assert!( + !client.base_url().contains("api.cortex.foundation"), + "client origin was {}", + client.base_url() + ); + + let profile = client + .fetch_me() + .await + .expect("loopback /v1/me must succeed"); + assert_eq!(profile.name.as_deref(), Some("Ada Lovelace")); + assert_eq!(profile.org_name.as_deref(), Some("Analytical Engines")); + + let requests = server.received_requests().await.expect("recorded requests"); + assert_eq!(requests.len(), 1, "only /v1/me should be contacted"); + 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}" + ); + let auth = requests[0] + .headers + .get("authorization") + .expect("bearer must be sent to the configured origin") + .to_str() + .unwrap(); + assert_eq!(auth, "Bearer staging-bearer"); + } + + #[tokio::test] + async fn fetch_me_times_out_when_the_body_stalls() { + 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_delay(ME_FETCH_TIMEOUT + Duration::from_secs(7)) + .set_body_json(serde_json::json!({ + "name": "too late" + })), + ) + .mount(&server) + .await; + + let client = CodeAgentClient::new(Some(server.uri()), Some("staging-bearer".into())); + let started = std::time::Instant::now(); + let err = client + .fetch_me() + .await + .expect_err("a stalled /v1/me must not succeed"); + assert!( + matches!(err, CortexError::Timeout), + "expected Timeout, got {err:?}" + ); + assert!( + started.elapsed() < ME_FETCH_TIMEOUT + Duration::from_secs(2), + "timeout must cover headers and body, elapsed {:?}", + started.elapsed() + ); + let msg = err.user_friendly_message(); + assert!( + msg.contains("temporarily unavailable"), + "timeout uses product copy: {msg}" + ); + } + + #[tokio::test] + async fn fetch_me_401_asks_for_cortex_login() { + 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)) + .mount(&server) + .await; + + let client = CodeAgentClient::new(Some(server.uri()), Some("revoked".into())); + let err = client.fetch_me().await.expect_err("401 is not success"); + let msg = err.user_friendly_message(); + assert!( + msg.contains("cortex login") || msg.contains("CORTEX_API_KEY"), + "{msg}" + ); + assert!(!msg.to_lowercase().contains("reqwest"), "{msg}"); + } +} diff --git a/src/cortex-engine/src/client/mod.rs b/src/cortex-engine/src/client/mod.rs index e751297b..05da0689 100644 --- a/src/cortex-engine/src/client/mod.rs +++ b/src/cortex-engine/src/client/mod.rs @@ -6,15 +6,18 @@ mod code_agent; mod computer; mod cortex; +mod me; pub mod runtime_contract; pub mod types; pub use code_agent::{ - CodeAgentClient, CodeHost, CodeHostPairing, CodeMessage, CodeSession, CodeTurnContext, - CodeTurnEvent, CodeTurnMode, ComputerKind, CreateCodeSession, DISCONNECTED_RUNTIME, - GUEST_TOKEN_PREFIX, GuestSession, cached_code_session_id, + AUTH_REQUIRED, CodeAgentClient, CodeHost, CodeHostPairing, CodeMessage, CodeSession, + CodeTurnContext, CodeTurnEvent, CodeTurnMode, ComputerKind, CreateCodeSession, + DISCONNECTED_RUNTIME, GUEST_TOKEN_PREFIX, GuestSession, cached_code_session_id, + normalize_api_base, }; pub use cortex::{CortexClient, CortexModel, PricingInfo}; +pub use me::{ME_FETCH_TIMEOUT, ME_PATH, MeProfile, me_url}; pub use types::*; use async_trait::async_trait; diff --git a/src/cortex-tui/src/app/methods.rs b/src/cortex-tui/src/app/methods.rs index 62ea70b3..dbecbd20 100644 --- a/src/cortex-tui/src/app/methods.rs +++ b/src/cortex-tui/src/app/methods.rs @@ -753,6 +753,21 @@ impl AppState { } } +impl AppState { + /// Apply identity from `GET /v1/me` without blocking startup. + pub fn apply_me_profile(&mut self, profile: cortex_engine::client::MeProfile) { + if profile.name.is_some() { + self.user_name = profile.name; + } + if profile.email.is_some() { + self.user_email = profile.email; + } + if profile.org_name.is_some() { + self.org_name = profile.org_name; + } + } +} + #[cfg(test)] mod agent_mode_tests { use super::*; @@ -781,4 +796,17 @@ mod agent_mode_tests { state.set_agent_mode("agent"); assert!(state.can_write()); } + + #[test] + fn apply_me_profile_sets_identity_fields() { + let mut state = AppState::default(); + state.apply_me_profile(cortex_engine::client::MeProfile { + name: Some("Ada Lovelace".into()), + email: Some("ada@example.com".into()), + org_name: Some("Analytical Engines".into()), + }); + assert_eq!(state.user_name.as_deref(), Some("Ada Lovelace")); + assert_eq!(state.user_email.as_deref(), Some("ada@example.com")); + assert_eq!(state.org_name.as_deref(), Some("Analytical Engines")); + } } diff --git a/src/cortex-tui/src/runner/app_runner/runner.rs b/src/cortex-tui/src/runner/app_runner/runner.rs index 9eeeb2d9..55631faf 100644 --- a/src/cortex-tui/src/runner/app_runner/runner.rs +++ b/src/cortex-tui/src/runner/app_runner/runner.rs @@ -471,54 +471,16 @@ impl AppRunner { } } - // ==================================================================== - // Fetch user info BEFORE showing TUI to avoid "User" placeholder - // ==================================================================== - let mut user_name: Option = None; - let mut user_email: Option = None; - let mut org_name: Option = None; - - // Fetch user info from /me API - wait for this before showing TUI - if let Some(token) = cortex_login::get_auth_token() { - tracing::debug!("Fetching user info from /me API..."); - if let Ok(client) = cortex_engine::create_default_client() { - match client - .get("https://api.cortex.foundation/auth/me") - .bearer_auth(&token) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(resp) if resp.status().is_success() => { - if let Ok(json) = resp.json::().await { - if let Some(name) = json.get("name").and_then(|v| v.as_str()) { - user_name = Some(name.to_string()); - tracing::info!("User info loaded: {}", name); - } - if let Some(email) = json.get("email").and_then(|v| v.as_str()) { - user_email = Some(email.to_string()); - } - if let Some(orgs) = json.get("organizations").and_then(|v| v.as_array()) - && let Some(first_org) = orgs.first() - && let Some(org) = - first_org.get("org_name").and_then(|v| v.as_str()) - { - org_name = Some(org.to_string()); - } - } - } - Ok(resp) => { - tracing::warn!("Failed to fetch user info: HTTP {}", resp.status()); - } - Err(e) => { - tracing::warn!("Failed to fetch user info: {}", e); - } - } - } - } + // Identity is fetched off the render path (`GET {CORTEX_API_URL}/v1/me`). + // Never block TUI open on this call — the event loop applies the profile + // when the 3s-capped request finishes. + let me_profile_task = spawn_me_profile_fetch( + provider_manager.api_url().to_string(), + cortex_login::get_auth_token(), + ); // ==================================================================== - // Now initialize TUI after we have user info + // Initialize TUI without waiting on network I/O // ==================================================================== let mut terminal = CortexTerminal::with_options(self.terminal_options)?; @@ -551,11 +513,6 @@ impl AppRunner { app_state.apply_tui_config(&self.config.tui); app_state.agent_entrypoint = launched_as_agent(); - // Set user info from pre-fetched data - app_state.user_name = user_name; - app_state.user_email = user_email; - app_state.org_name = org_name; - // Load last used theme from config if let Ok(config) = crate::providers::config::CortexConfig::load() && let Some(theme) = config.get_last_theme() @@ -738,7 +695,8 @@ impl AppRunner { .with_provider_manager(provider_manager) .try_with_cortex_session(cortex_session)? .with_tool_registry(tool_registry) - .with_sandbox_policy(self.config.sandbox_policy.clone()); + .with_sandbox_policy(self.config.sandbox_policy.clone()) + .with_me_profile_task(me_profile_task); // Add unified executor if available if let Some(executor) = unified_executor { @@ -982,6 +940,24 @@ fn launched_as_agent() -> bool { .unwrap_or(false) } +/// `GET {api_url}/v1/me` on the configured origin. Never contacts a hard-coded host. +pub(crate) fn spawn_me_profile_fetch( + api_url: String, + token: Option, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let token = token.filter(|t| !t.is_empty())?; + let client = cortex_engine::client::CodeAgentClient::new(Some(api_url), Some(token)); + match client.fetch_me().await { + Ok(profile) => Some(profile), + Err(e) => { + tracing::warn!("Failed to fetch user info: {}", e.user_friendly_message()); + None + } + } + }) +} + // ============================================================================ // Tests // ============================================================================ diff --git a/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs b/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs index 820eff12..90d0f85b 100644 --- a/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs +++ b/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs @@ -89,3 +89,15 @@ fn test_app_runner_cortex_session_id() { // Direct provider mode should still be enabled assert!(runner.use_direct_provider); } + +#[test] +fn me_profile_request_url_uses_configured_origin_never_production_or_auth_me() { + let url = cortex_engine::client::me_url("http://127.0.0.1:18081/"); + assert_eq!(url, "http://127.0.0.1:18081/v1/me"); + assert!(!url.contains("api.cortex.foundation")); + assert!(!url.contains("/auth/me")); + assert_eq!( + cortex_engine::client::me_url("https://api.cortex.foundation"), + "https://api.cortex.foundation/v1/me" + ); +} diff --git a/src/cortex-tui/src/runner/event_loop/core.rs b/src/cortex-tui/src/runner/event_loop/core.rs index 057f5059..879f9b8a 100644 --- a/src/cortex-tui/src/runner/event_loop/core.rs +++ b/src/cortex-tui/src/runner/event_loop/core.rs @@ -178,6 +178,9 @@ pub struct EventLoop { Option>, /// Servers the user is stopping (disconnect is not a drop). pub(super) mcp_stopping: std::collections::HashSet, + + /// In-flight `GET /v1/me` on the configured API origin (off the render path). + pub(super) me_profile_task: Option>>, } impl EventLoop { @@ -234,6 +237,7 @@ impl EventLoop { mcp_manager, mcp_event_rx: Some(mcp_event_rx), mcp_stopping: std::collections::HashSet::new(), + me_profile_task: None, } } diff --git a/src/cortex-tui/src/runner/event_loop/input.rs b/src/cortex-tui/src/runner/event_loop/input.rs index b884fcf9..e13077e5 100644 --- a/src/cortex-tui/src/runner/event_loop/input.rs +++ b/src/cortex-tui/src/runner/event_loop/input.rs @@ -119,6 +119,10 @@ impl EventLoop { || brain_animating; } + if self.apply_pending_me_profile().await { + needs_render = true; + } + // Render frame (respecting frame time to avoid over-rendering) // During idle states, we can skip renders entirely if needs_render && self.last_render.elapsed() >= self.min_frame_time { diff --git a/src/cortex-tui/src/runner/event_loop/me.rs b/src/cortex-tui/src/runner/event_loop/me.rs new file mode 100644 index 00000000..4a80f633 --- /dev/null +++ b/src/cortex-tui/src/runner/event_loop/me.rs @@ -0,0 +1,38 @@ +//! Off-render-path `GET /v1/me` apply for the TUI event loop. + +use tokio::task::JoinHandle; + +use super::core::EventLoop; + +impl EventLoop { + /// Attach a background `GET /v1/me` so identity can land after the first frame. + pub fn with_me_profile_task( + mut self, + task: JoinHandle>, + ) -> Self { + self.me_profile_task = Some(task); + self + } + + /// Apply a finished `/v1/me` fetch without waiting on the render path. + pub(super) async fn apply_pending_me_profile(&mut self) -> bool { + let Some(handle) = self.me_profile_task.take() else { + return false; + }; + if !handle.is_finished() { + self.me_profile_task = Some(handle); + return false; + } + match handle.await { + Ok(Some(profile)) => { + self.app_state.apply_me_profile(profile); + true + } + Ok(None) => false, + Err(e) => { + tracing::debug!("User info task ended: {e}"); + false + } + } + } +} diff --git a/src/cortex-tui/src/runner/event_loop/mod.rs b/src/cortex-tui/src/runner/event_loop/mod.rs index bcd82664..d0b3c150 100644 --- a/src/cortex-tui/src/runner/event_loop/mod.rs +++ b/src/cortex-tui/src/runner/event_loop/mod.rs @@ -32,6 +32,7 @@ mod commands; mod core; mod input; mod local_workflows; +mod me; mod modal; mod mouse; mod rendering; diff --git a/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs b/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs index d9c0e183..ed5ddc1c 100644 --- a/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs +++ b/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs @@ -332,3 +332,33 @@ fn ux_contract_unicode_auto_title_and_concurrent_append_are_lossless() { runner.app_state.input.set_text("界🙂 Unicode input"); assert_views(&mut runner, "Unicode input"); } + +#[tokio::test] +async fn ux_contract_me_profile_applies_off_the_render_path() { + let (_temp, mut runner) = fixture(); + let handle = tokio::spawn(async { + Some(cortex_engine::client::MeProfile { + name: Some("Ada Lovelace".into()), + email: Some("ada@example.com".into()), + org_name: Some("Analytical Engines".into()), + }) + }); + runner.me_profile_task = Some(handle); + let mut applied = false; + for _ in 0..50 { + if runner.apply_pending_me_profile().await { + applied = true; + break; + } + tokio::task::yield_now().await; + } + assert!( + applied, + "finished /v1/me task must apply without blocking startup" + ); + assert_eq!(runner.app_state.user_name.as_deref(), Some("Ada Lovelace")); + assert_eq!( + runner.app_state.org_name.as_deref(), + Some("Analytical Engines") + ); +} diff --git a/src/cortex-tui/src/views/minimal_session/tests.rs b/src/cortex-tui/src/views/minimal_session/tests.rs index 39535bf5..4b6df329 100644 --- a/src/cortex-tui/src/views/minimal_session/tests.rs +++ b/src/cortex-tui/src/views/minimal_session/tests.rs @@ -65,6 +65,31 @@ mod harness_snapshots { } } + #[test] + fn snapshot_motd_shows_org_from_me_profile() { + let mut state = AppState::default(); + state.apply_me_profile(cortex_engine::client::MeProfile { + name: Some("Ada Lovelace".into()), + email: Some("ada@example.com".into()), + org_name: Some("Analytical Engines".into()), + }); + let colors = crate::ui::colors::AdaptiveColors::from_theme_name("Cortex Night"); + for (width, height) in [(80, 24), (120, 40)] { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + super::super::rendering::_render_motd(area, &mut buf, &colors, &state); + let text = buffer_text(&buf); + assert!( + text.contains("Analytical Engines"), + "{width}x{height} motd should show the live org, got: {text}" + ); + assert!( + !text.contains("Personal") || text.contains("Analytical Engines"), + "{width}x{height}: {text}" + ); + } + } + #[test] fn snapshot_session_with_turn() { let mut state = AppState::default();