From cce9d3228b5d649cea400bc6d19514a0342ad998 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Sat, 7 Nov 2026 10:29:57 +0000 Subject: [PATCH] Use runtime authentication for Analytics requests (#60101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - Honor `RuntimeAgentInstances` and the configured runtime auth mode for Analytics plan discovery and report requests to OpenAI endpoints. - Share installation ID resolution and Codex runtime registration setup through `codex-runtime-agent-instance`. - Preserve typed runtime authentication errors so expired credentials retain the “Sign in again to load Analytics” guidance. ## Testing Add coverage for both runtime auth modes across regional and staging endpoints, fresh assertions for each request, sign-in guidance after bootstrap failure, redirect rejection, and invalid organization/project selectors. GitOrigin-RevId: 50e0d959b3abd81ebe7a6a584e272f14cf52e504 --- codex-rs/Cargo.lock | 4 + .../src/transport/remote_control/auth.rs | 2 +- codex-rs/backend-client/Cargo.toml | 1 + .../backend-client/src/analytics_session.rs | 60 ++++- .../src/client/runtime_auth_tests.rs | 21 ++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/lib.rs | 3 +- codex-rs/core/src/plugins/mod.rs | 3 +- codex-rs/core/src/runtime_auth.rs | 13 +- codex-rs/core/src/runtime_auth_tests.rs | 11 +- codex-rs/core/src/thread_manager_tests.rs | 3 +- .../tests/suite/analytics_runtime_auth.rs | 142 +++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/model-provider/Cargo.toml | 1 + codex-rs/model-provider/src/auth.rs | 3 +- codex-rs/model-provider/src/client_auth.rs | 2 +- codex-rs/model-provider/src/runtime_auth.rs | 18 +- codex-rs/runtime-agent-instance/Cargo.toml | 3 +- codex-rs/runtime-agent-instance/src/auth.rs | 2 + .../runtime-agent-instance/src/binding.rs | 4 + .../src/installation_id.rs | 13 +- codex-rs/runtime-agent-instance/src/lib.rs | 2 + .../runtime-agent-instance/src/manager.rs | 18 ++ codex-rs/tui/src/analytics/client.rs | 9 + .../tui/src/analytics/runtime_auth_tests.rs | 239 ++++++++++++++++++ 25 files changed, 529 insertions(+), 50 deletions(-) create mode 100644 codex-rs/core/tests/suite/analytics_runtime_auth.rs rename codex-rs/{core => runtime-agent-instance}/src/installation_id.rs (91%) create mode 100644 codex-rs/tui/src/analytics/runtime_auth_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9a0d551632..97697bebc5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2479,6 +2479,7 @@ dependencies = [ "codex-protocol", "codex-response-debug-context", "codex-runtime-agent-instance", + "codex-utils-absolute-path", "http 1.4.0", "pretty_assertions", "serde", @@ -3017,6 +3018,7 @@ dependencies = [ "codex-apply-patch", "codex-async-utils", "codex-attachment-store", + "codex-backend-client", "codex-client", "codex-code-mode", "codex-config", @@ -4342,6 +4344,7 @@ dependencies = [ name = "codex-model-provider" version = "0.0.0" dependencies = [ + "anyhow", "base64 0.22.1", "chrono", "codex-agent-identity", @@ -4881,6 +4884,7 @@ dependencies = [ "codex-features", "codex-http-client", "codex-login", + "codex-utils-absolute-path", "codex-utils-redacted-string", "ed25519-dalek", "futures", diff --git a/codex-rs/app-server-transport/src/transport/remote_control/auth.rs b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs index 1b6127c419..2d50bca37a 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/auth.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs @@ -110,7 +110,7 @@ impl RemoteControlConnectionAuth { &HeaderMap::new(), )) .await - .map_err(auth_error)?; + .map_err(|error| auth_error(error.into()))?; let factory = resolved .bind_http_client_factory(self.http_client_factory.clone()) .map_err(auth_error)?; diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index d1d476e30a..d739f26812 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -22,6 +22,7 @@ codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-api = { workspace = true } codex-features = { workspace = true } codex-http-client = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-runtime-agent-instance = { workspace = true } diff --git a/codex-rs/backend-client/src/analytics_session.rs b/codex-rs/backend-client/src/analytics_session.rs index d6c00efae6..3445a1a796 100644 --- a/codex-rs/backend-client/src/analytics_session.rs +++ b/codex-rs/backend-client/src/analytics_session.rs @@ -3,11 +3,17 @@ use crate::Client; use crate::RequestError; +use codex_features::RuntimeAuthMode; use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_login::AuthManagerConfig; use codex_login::CodexAuth; use codex_protocol::account::PlanType; +use codex_runtime_agent_instance::RegistrationError; +use codex_runtime_agent_instance::RuntimeAuthError; +use codex_runtime_agent_instance::RuntimeAuthManager; +use codex_runtime_agent_instance::resolve_installation_id; +use codex_utils_absolute_path::AbsolutePathBuf; use std::sync::Arc; /// Non-secret account metadata associated with an analytics session. @@ -31,6 +37,7 @@ impl AnalyticsSession { pub async fn from_config( config: &impl AuthManagerConfig, http_client_factory: HttpClientFactory, + runtime_auth_mode: Option, ) -> Result { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false) @@ -51,12 +58,53 @@ impl AnalyticsSession { email: auth.get_account_email(), plan_type: None, }; - let client = Client::new_without_redirects(config.chatgpt_base_url(), http_client_factory) - .with_auth_provider(codex_model_provider::auth_provider_from_auth_manager( - Arc::clone(&auth_manager), - &auth, - )) - .map_err(|_| "Couldn't initialize local Analytics authentication.")?; + let base_url = config.chatgpt_base_url(); + let runtime_auth_mode = runtime_auth_mode + .filter(|_| codex_runtime_agent_instance::is_openai_api_url(&base_url)); + let client = match runtime_auth_mode { + Some(mode) => { + let home = AbsolutePathBuf::from_absolute_path(config.codex_home()) + .map_err(|_| "Couldn't initialize local Analytics authentication.")?; + let installation_id = resolve_installation_id(&home) + .await + .map_err(|_| "Couldn't initialize local Analytics authentication.")?; + let runtime = ( + Arc::new(RuntimeAuthManager::for_codex( + Arc::clone(&auth_manager), + &base_url, + &installation_id, + )), + mode, + ); + Client::from_auth_with_runtime_without_redirects( + &base_url, + &auth, + http_client_factory, + Some(&runtime), + /*headers*/ &Default::default(), + ) + .await + .map_err(|error| { + match error.downcast_ref::() { + Some( + RuntimeAuthError::RefreshFailed + | RuntimeAuthError::RefreshTokenFailed(_) + | RuntimeAuthError::Registration(RegistrationError::Rejected { + status: http::StatusCode::UNAUTHORIZED, + .. + }), + ) => "Sign in again to load Analytics.", + _ => "Couldn't authenticate the Analytics request.", + } + })? + } + None => Client::new_without_redirects(base_url, http_client_factory) + .with_auth_provider(codex_model_provider::auth_provider_from_auth_manager( + Arc::clone(&auth_manager), + &auth, + )) + .map_err(|_| "Couldn't initialize local Analytics authentication.")?, + }; let mut session = Self { client, auth_manager, diff --git a/codex-rs/backend-client/src/client/runtime_auth_tests.rs b/codex-rs/backend-client/src/client/runtime_auth_tests.rs index a3e4c5ac3d..06272ab38c 100644 --- a/codex-rs/backend-client/src/client/runtime_auth_tests.rs +++ b/codex-rs/backend-client/src/client/runtime_auth_tests.rs @@ -218,6 +218,27 @@ async fn client_registers_in_requested_organization_when_default_is_denied() { HeaderName::from_static("openai-organization"), HeaderValue::from_static("org-b"), )]); + for name in ["openai-organization", "openai-project"] { + let mut invalid_headers = headers.clone(); + invalid_headers.insert( + HeaderName::from_static(name), + HeaderValue::from_bytes(b"\xff") + .expect("non-ASCII selector is a valid header value"), + ); + assert!( + Client::from_auth_with_selected_runtime( + server.uri(), + &auth, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + Some(&runtime), + &invalid_headers, + Client::new, + ) + .await + .is_err(), + "invalid selectors must not fall back to the default resource" + ); + } let client = Client::from_auth_with_selected_runtime( server.uri(), &auth, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a9acbcdfd9..5c26e95f6c 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -142,6 +142,7 @@ openssl-sys = { workspace = true, features = ["vendored"] } openssl-sys = { workspace = true, features = ["vendored"] } [dev-dependencies] +codex-backend-client = { workspace = true } codex-message-board = { workspace = true } codex-agent-message-board-host = { workspace = true } assert_cmd = { workspace = true } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 985bc7c99c..f95157512e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -108,7 +108,6 @@ mod hook_environment_executor; mod hook_mcp_executor; mod hook_runtime; mod image_preparation; -mod installation_id; pub(crate) mod mcp; mod mcp_skill_dependencies; mod mcp_tool_approval_templates; @@ -275,6 +274,7 @@ pub use client_common::Prompt; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; pub use codex_prompts::REVIEW_PROMPT; +pub use codex_runtime_agent_instance::resolve_installation_id; pub use compact::content_items_to_text; pub use current_time::SleepFuture; pub use current_time::TimeFuture; @@ -284,7 +284,6 @@ pub use exec_policy::ExecPolicyError; pub use exec_policy::check_execpolicy_for_warnings; pub use exec_policy::format_exec_policy_error_with_source; pub use exec_policy::load_exec_policy; -pub use installation_id::resolve_installation_id; pub mod compact; mod memory_usage; pub mod otel_init; diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index b2753e587f..856083aa74 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -61,8 +61,7 @@ pub async fn plugins_manager_for_config_with_runtime( if !codex_runtime_agent_instance::is_openai_api_url(&config.chatgpt_base_url) { return Ok(plugins_manager_for_config(config, auth_manager)); } - let installation_id = - crate::installation_id::resolve_installation_id(&config.codex_home).await?; + let installation_id = crate::resolve_installation_id(&config.codex_home).await?; let runtime = crate::runtime_auth::build_runtime_auth_manager( config, Arc::clone(&auth_manager), diff --git a/codex-rs/core/src/runtime_auth.rs b/codex-rs/core/src/runtime_auth.rs index ad26b763a3..eedc99dd5b 100644 --- a/codex-rs/core/src/runtime_auth.rs +++ b/codex-rs/core/src/runtime_auth.rs @@ -5,8 +5,6 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_protocol::error::Result as CodexResult; use codex_runtime_agent_instance::RuntimeAuthManager; -use codex_runtime_agent_instance::RuntimeRegistrationMetadata; -use codex_runtime_agent_instance::RuntimeRole; use std::sync::Arc; use std::sync::OnceLock; @@ -39,15 +37,10 @@ pub(crate) fn runtime_for_config( .enabled(Feature::RuntimeAgentInstances) .then(|| { Arc::clone(slot.get_or_init(|| { - Arc::new(RuntimeAuthManager::for_chatgpt( + Arc::new(RuntimeAuthManager::for_codex( credentials, - installation_id.to_owned(), - config.chatgpt_base_url.clone(), - RuntimeRegistrationMetadata { - harness: Some("codex".to_string()), - harness_version: Some(env!("CARGO_PKG_VERSION").to_string()), - role: RuntimeRole::Orchestrator, - }, + &config.chatgpt_base_url, + installation_id, )) })) }) diff --git a/codex-rs/core/src/runtime_auth_tests.rs b/codex-rs/core/src/runtime_auth_tests.rs index 3269cc7e23..1b105b7e80 100644 --- a/codex-rs/core/src/runtime_auth_tests.rs +++ b/codex-rs/core/src/runtime_auth_tests.rs @@ -34,15 +34,10 @@ fn realtime_rejects_a_different_registration_environment() { ("https://chatgpt.com/backend-api", false), ("https://chatgpt-staging.com/backend-api", true), ] { - let runtime = RuntimeAuthManager::for_chatgpt( + let runtime = RuntimeAuthManager::for_codex( AuthManager::from_auth_for_testing(codex_login::CodexAuth::from_api_key("test-key")), - uuid::Uuid::nil().to_string(), - chatgpt_base_url.to_owned(), - RuntimeRegistrationMetadata { - harness: None, - harness_version: None, - role: RuntimeRole::Orchestrator, - }, + chatgpt_base_url, + &uuid::Uuid::nil().to_string(), ); for (destination, staging) in [ ("wss://api.openai.com/v1", false), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index c6ca9736e4..c0b813880d 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -7,7 +7,6 @@ use crate::config::RolloutBudgetConfig; use crate::config::ThreadStoreConfig; use crate::config::test_config; use crate::init_state_db; -use crate::installation_id::INSTALLATION_ID_FILENAME; use crate::mcp::McpEnvironmentScope; use crate::mcp::McpThreadIdentity; use crate::rollout::RolloutRecorder; @@ -2899,7 +2898,7 @@ async fn explicit_installation_id_skips_codex_home_file() { .await .expect("start thread with explicit installation id"); - assert!(!config.codex_home.join(INSTALLATION_ID_FILENAME).exists()); + assert!(!config.codex_home.join("installation_id").exists()); assert_eq!(thread.thread.session.installation_id, installation_id); thread diff --git a/codex-rs/core/tests/suite/analytics_runtime_auth.rs b/codex-rs/core/tests/suite/analytics_runtime_auth.rs new file mode 100644 index 0000000000..ca6a496d0e --- /dev/null +++ b/codex-rs/core/tests/suite/analytics_runtime_auth.rs @@ -0,0 +1,142 @@ +//! Analytics plan discovery must not follow redirects with runtime authentication. + +use anyhow::Result; +use base64::Engine; +use codex_backend_client::AnalyticsSession; +use codex_features::RuntimeAuthMode; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use codex_login::AuthCredentialsStoreMode; +use core_test_support::load_default_config_for_test; +use core_test_support::runtime_auth::PROBE_SERVER; +use core_test_support::runtime_auth::run_harness_through_proxy_with_env; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn analytics_does_not_follow_runtime_redirects() -> Result<()> { + skip_if_no_network!(Ok(())); + let token = format!( + "e30.{}.c2ln", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + json!({ + "exp": 4_102_444_800_u64, + "iss": "https://auth.openai.com", + "session_id": "session-analytics", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-analytics", + "chatgpt_account_id": "account-analytics", + }, + }) + .to_string(), + ), + ); + if std::env::var(PROBE_SERVER).is_ok() { + for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] { + let home = tempfile::tempdir()?; + codex_login::auth::login_with_chatgpt_auth_tokens( + home.path(), + &token, + "account-analytics", + Some("plus"), + )?; + let mut config = load_default_config_for_test(&home).await; + config.chatgpt_base_url = "https://chatgpt.com/backend-api".into(); + config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::Ephemeral; + let result = AnalyticsSession::from_config( + &config, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + Some(mode), + ) + .await; + assert!( + result.is_err(), + "Analytics must reject a redirected plan response" + ); + } + return Ok(()); + } + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/agent/register")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "runtime_agent_instance_id": "runtime-analytics", + "agent_host_id": "host-analytics", + "actor": { + "kind": "chatgpt", + "user_id": "user-analytics", + "account_id": "account-analytics", + }, + }))) + .expect(/*r*/ 2) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/accounts/check")) + .respond_with(ResponseTemplate::new(/*s*/ 307).insert_header( + "Location", + "https://chatgpt.com/backend-api/redirected-plan", + )) + .expect(/*r*/ 2) + .mount(&server) + .await; + Mock::given(path("/backend-api/redirected-plan")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": [], + "account_ordering": [], + }))) + .expect(/*r*/ 0) + .mount(&server) + .await; + + run_harness_through_proxy_with_env( + &server, + "suite::analytics_runtime_auth::analytics_does_not_follow_runtime_redirects", + /*realtime_target*/ None, + &[ + ("OPENAI_API_KEY", None), + ("CODEX_API_KEY", None), + ("OPENAI_FEDERATION_RULE_ID", None), + ("OPENAI_IDENTITY_TOKEN_FILE", None), + ], + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("requests should be recorded"); + let discovery: Vec<_> = requests + .iter() + .filter(|request| request.url.path() == "/backend-api/wham/accounts/check") + .collect(); + assert_eq!(discovery.len(), 2); + assert_eq!( + discovery[0].headers["authorization"], + format!("Bearer {token}") + ); + assert!( + discovery[0] + .headers + .contains_key("x-openai-agent-assertion") + ); + assert!( + discovery[1].headers["authorization"] + .to_str()? + .starts_with("AgentAssertion ") + ); + assert!( + !discovery[1] + .headers + .contains_key("x-openai-agent-assertion") + ); + server.verify().await; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 163107b8ec..b1f7a42313 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -48,6 +48,7 @@ mod agent_execution; mod agent_message_wake; mod agent_websocket; mod agents_md; +mod analytics_runtime_auth; mod app_tool_exposure; mod apply_patch_cli; mod apply_patch_serialization; diff --git a/codex-rs/model-provider/Cargo.toml b/codex-rs/model-provider/Cargo.toml index 4d0a9aadfd..bb4cb58b9e 100644 --- a/codex-rs/model-provider/Cargo.toml +++ b/codex-rs/model-provider/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = { workspace = true } codex-utils-process = { workspace = true } chrono = { workspace = true } codex-api = { workspace = true } diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 8fd61b3fa6..78c2497907 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -24,6 +24,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; use codex_protocol::protocol::SessionSource; use codex_runtime_agent_instance::RuntimeAuth; +use codex_runtime_agent_instance::RuntimeAuthError; use codex_runtime_agent_instance::RuntimeAuthManager; use http::HeaderMap; use http::HeaderValue; @@ -470,7 +471,7 @@ pub async fn auth_provider_for_request( auth: &CodexAuth, runtime: Option<&(Arc, RuntimeAuthMode)>, headers: &HeaderMap, -) -> Result { +) -> Result { observe_auth_selection(runtime.map(|(_, mode)| *mode), "request", async { match runtime { Some((manager, mode)) => { diff --git a/codex-rs/model-provider/src/client_auth.rs b/codex-rs/model-provider/src/client_auth.rs index 28f67aac0f..16c9497ad5 100644 --- a/codex-rs/model-provider/src/client_auth.rs +++ b/codex-rs/model-provider/src/client_auth.rs @@ -43,7 +43,7 @@ impl ClientAuth { runtime: Option<&(Arc, RuntimeAuthMode)>, factory: HttpClientFactory, headers: &HeaderMap, - ) -> Result<(Self, HttpClientFactory), AuthError> { + ) -> anyhow::Result<(Self, HttpClientFactory)> { let resolved = crate::auth_provider_for_request(auth, runtime, headers).await?; let factory = resolved.bind_http_client_factory(factory)?; let source = match (runtime, resolved.runtime) { diff --git a/codex-rs/model-provider/src/runtime_auth.rs b/codex-rs/model-provider/src/runtime_auth.rs index 801655ee55..888ad6c31d 100644 --- a/codex-rs/model-provider/src/runtime_auth.rs +++ b/codex-rs/model-provider/src/runtime_auth.rs @@ -26,8 +26,7 @@ pub(crate) async fn select_runtime_auth( manager: &Arc, headers: &HeaderMap, ) -> CodexResult { - let selection = - resource_selection(headers).map_err(|error| codex_api::map_api_error(error.into()))?; + let selection = resource_selection(headers).map_err(map_runtime_auth_error)?; manager .auth_with_selection(&selection) .await @@ -40,8 +39,7 @@ pub async fn select_runtime_auth_for( auth: &CodexAuth, headers: &HeaderMap, ) -> CodexResult { - let selection = - resource_selection(headers).map_err(|error| codex_api::map_api_error(error.into()))?; + let selection = resource_selection(headers).map_err(map_runtime_auth_error)?; binding .auth_for(auth, &selection) .await @@ -67,21 +65,19 @@ pub(crate) async fn select_runtime_auth_for_source( async move { manager.auth_for_source(source_generation, &selection).await }, ) .await + .map_err(AuthError::from) } // Once token rotation begins, let it finish persisting even if the request is cancelled. pub(crate) async fn finish_runtime_selection( selection: impl Future> + Send + 'static, -) -> Result { - tokio::spawn(selection) - .await - .map_err(|error| AuthError::Build(format!("runtime auth task failed: {error}")))? - .map_err(AuthError::from) +) -> Result { + tokio::spawn(selection).await? } pub(crate) fn resource_selection( headers: &HeaderMap, -) -> Result { +) -> Result { let selector = |name| { headers .get(name) @@ -89,7 +85,7 @@ pub(crate) fn resource_selection( value .to_str() .map(str::to_owned) - .map_err(|_| AuthError::Build(format!("invalid {name} resource selector"))) + .map_err(|_| RuntimeAuthError::InvalidResourceSelection(name)) }) .transpose() }; diff --git a/codex-rs/runtime-agent-instance/Cargo.toml b/codex-rs/runtime-agent-instance/Cargo.toml index 53fd913ab3..9b8b96f2aa 100644 --- a/codex-rs/runtime-agent-instance/Cargo.toml +++ b/codex-rs/runtime-agent-instance/Cargo.toml @@ -17,6 +17,7 @@ codex-api = { workspace = true } codex-features = { workspace = true } codex-http-client = { workspace = true } codex-login = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-redacted-string = { workspace = true } ed25519-dalek = { workspace = true } http = { workspace = true } @@ -24,7 +25,7 @@ rand = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["sync"] } +tokio = { workspace = true, features = ["fs", "rt", "sync"] } url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4"] } zeroize = { workspace = true } diff --git a/codex-rs/runtime-agent-instance/src/auth.rs b/codex-rs/runtime-agent-instance/src/auth.rs index 4f0475a567..44525f959f 100644 --- a/codex-rs/runtime-agent-instance/src/auth.rs +++ b/codex-rs/runtime-agent-instance/src/auth.rs @@ -181,9 +181,11 @@ impl From for AuthError { RuntimeAuthError::Unavailable | RuntimeAuthError::Unsupported | RuntimeAuthError::UnsupportedResourceSelection + | RuntimeAuthError::InvalidResourceSelection(_) | RuntimeAuthError::Changed | RuntimeAuthError::RefreshFailed | RuntimeAuthError::RefreshTokenFailed(_) + | RuntimeAuthError::Task(_) | RuntimeAuthError::KeyGeneration | RuntimeAuthError::Registration( RegistrationError::InvalidEndpoint diff --git a/codex-rs/runtime-agent-instance/src/binding.rs b/codex-rs/runtime-agent-instance/src/binding.rs index d810d5f7c7..7302f8c38a 100644 --- a/codex-rs/runtime-agent-instance/src/binding.rs +++ b/codex-rs/runtime-agent-instance/src/binding.rs @@ -386,12 +386,16 @@ pub enum RuntimeAuthError { Unsupported, #[error("organization and project selection requires an OpenAI API key")] UnsupportedResourceSelection, + #[error("invalid {0} resource selector")] + InvalidResourceSelection(&'static str), #[error("credentials changed during runtime registration; try again")] Changed, #[error("runtime registration could not refresh actor credentials")] RefreshFailed, #[error(transparent)] RefreshTokenFailed(#[from] RefreshTokenFailedError), + #[error("runtime auth task failed: {0}")] + Task(#[from] tokio::task::JoinError), #[error("failed to generate runtime signing key")] KeyGeneration, #[error(transparent)] diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/runtime-agent-instance/src/installation_id.rs similarity index 91% rename from codex-rs/core/src/installation_id.rs rename to codex-rs/runtime-agent-instance/src/installation_id.rs index 51f7b7a455..aad3102f47 100644 --- a/codex-rs/core/src/installation_id.rs +++ b/codex-rs/runtime-agent-instance/src/installation_id.rs @@ -14,7 +14,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use tokio::fs; use uuid::Uuid; -pub(crate) const INSTALLATION_ID_FILENAME: &str = "installation_id"; +const INSTALLATION_ID_FILENAME: &str = "installation_id"; pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result { let path = codex_home.join(INSTALLATION_ID_FILENAME); @@ -76,7 +76,7 @@ pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result, + chatgpt_base_url: &str, + installation_id: &str, + ) -> Self { + Self::for_chatgpt( + credentials, + installation_id.to_owned(), + chatgpt_base_url.to_owned(), + RuntimeRegistrationMetadata { + harness: Some("codex".to_string()), + harness_version: Some(env!("CARGO_PKG_VERSION").to_string()), + role: crate::RuntimeRole::Orchestrator, + }, + ) + } + pub fn registration_url(&self) -> Result { self.login.registration_url() } diff --git a/codex-rs/tui/src/analytics/client.rs b/codex-rs/tui/src/analytics/client.rs index 50e66f3ee0..0a7a761402 100644 --- a/codex-rs/tui/src/analytics/client.rs +++ b/codex-rs/tui/src/analytics/client.rs @@ -10,6 +10,7 @@ use crate::legacy_core::config::Config; use codex_backend_client::AnalyticsReport; use codex_backend_client::AnalyticsSession; use codex_backend_client::RequestError; +use codex_features::Feature; use codex_protocol::account::PlanType; use std::collections::HashMap; use std::sync::Arc; @@ -73,6 +74,10 @@ impl Live { let session = AnalyticsSession::from_config( self.config.as_ref(), self.config.http_client_factory(), + self.config + .features + .enabled(Feature::RuntimeAgentInstances) + .then_some(self.config.runtime_auth_mode), ) .await?; let credit_groups = Grouping::credit_groupings(session.account().plan_type) @@ -280,3 +285,7 @@ mod connection_tests; #[cfg(test)] #[path = "account_plan_tests.rs"] mod account_plan_tests; + +#[cfg(test)] +#[path = "runtime_auth_tests.rs"] +mod runtime_auth_tests; diff --git a/codex-rs/tui/src/analytics/runtime_auth_tests.rs b/codex-rs/tui/src/analytics/runtime_auth_tests.rs new file mode 100644 index 0000000000..22613ede9e --- /dev/null +++ b/codex-rs/tui/src/analytics/runtime_auth_tests.rs @@ -0,0 +1,239 @@ +//! Exercise Analytics construction and reauthentication through production HTTPS routes. + +#![cfg(test)] + +use super::Live; +use super::tests::sign_in; +use crate::legacy_core::config::ConfigBuilder; +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use codex_backend_client::AnalyticsReport; +use codex_backend_client::AnalyticsResponse; +use codex_config::LoaderOverrides; +use codex_features::Feature; +use codex_features::RuntimeAuthMode; +use codex_protocol::account::PlanType; +use core_test_support::runtime_auth::PROBE_SERVER; +use core_test_support::runtime_auth::run_harness_through_proxy; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::collections::BTreeSet; +use std::sync::Arc; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +async fn runtime_client( + base_url: &str, + mode: RuntimeAuthMode, +) -> Result<(tempfile::TempDir, Live)> { + let home = tempfile::tempdir()?; + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + config.chatgpt_base_url = base_url.to_string(); + config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File; + config.features.enable(Feature::RuntimeAgentInstances)?; + config.runtime_auth_mode = mode; + sign_in(home.path(), "account-a", "user-a", "plus"); + Ok((home, Live::new(Arc::new(config), "2026-01-15".parse()?))) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn analytics_runtime_covers_regional_account_and_report_requests() -> Result<()> { + skip_if_no_network!(Ok(())); + let bases = [ + "https://chatgpt.com/backend-api", + "https://us.chatgpt.com/backend-api", + "https://chatgpt-staging.com/backend-api", + "https://us.chatgpt-staging.com/backend-api", + ]; + let modes = [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly]; + if std::env::var(PROBE_SERVER).is_ok() { + for base in bases { + for mode in modes { + let (_home, live) = runtime_client(base, mode).await?; + let session = live + .session() + .await + .map_err(anyhow::Error::msg) + .with_context(|| format!("{base} {mode:?}"))?; + assert_eq!(session.backend.account().plan_type, Some(PlanType::Pro)); + let response = session + .backend + .request(|client| async move { + client + .get_account_analytics( + AnalyticsReport::Usage, + "2026-01-01", + "2026-01-15", + ) + .await + }) + .await?; + assert_eq!( + response, + AnalyticsResponse::Usage( + codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default(), + ), + ); + } + } + return Ok(()); + } + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/agent/register")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "runtime_agent_instance_id": "runtime-analytics", "agent_host_id": "host-a", + "actor": {"kind": "chatgpt", "user_id": "user-a", "account_id": "account-a"}, + }))) + .expect(/*r*/ 8) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/accounts/check")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "accounts": [{"id": "account-a", "plan_type": "pro"}], + "account_ordering": ["account-a"], + }))) + .expect(/*r*/ 8) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage/daily-token-usage-breakdown")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []}))) + .expect(/*r*/ 8) + .mount(&server) + .await; + run_harness_through_proxy( + &server, + "analytics::client::runtime_auth_tests::analytics_runtime_covers_regional_account_and_report_requests", + /*realtime_target*/ None, + ).await?; + server.verify().await; + let requests = server + .received_requests() + .await + .context("captured requests")?; + assert_eq!(requests.len(), 24); + let mut proofs = BTreeSet::new(); + for (index, requests) in requests.as_chunks::<3>().0.iter().enumerate() { + let base = bases[index / modes.len()]; + let mode = modes[index % modes.len()]; + let registration = &requests[0]; + assert_eq!(registration.url.path(), "/agent/register"); + assert_eq!( + registration.headers["host"], + if base.contains("staging") { + "auth.api.openai.org" + } else { + "auth.openai.com" + }, + ); + let body: Value = registration.body_json()?; + let credential = body["actor_auth"]["credential"] + .as_str() + .context("actor credential")?; + for request in &requests[1..] { + assert_eq!( + request.headers["host"], + base.strip_prefix("https://") + .expect("HTTPS test URL") + .strip_suffix("/backend-api") + .expect("test API path"), + ); + assert_eq!(request.headers["chatgpt-account-id"], "account-a"); + let authorization = request.headers["authorization"].to_str()?; + let proof = match mode { + RuntimeAuthMode::DualHeader => { + assert_eq!(authorization, format!("Bearer {credential}")); + request.headers["x-openai-agent-assertion"].to_str()? + } + RuntimeAuthMode::AssertionOnly => { + assert!(!request.headers.contains_key("x-openai-agent-assertion")); + authorization + .strip_prefix("AgentAssertion ") + .context("runtime authorization")? + } + }; + assert!( + proofs.insert(proof.to_owned()), + "every request needs a fresh assertion" + ); + let payload = proof + .strip_prefix("1.") + .context("runtime assertion version")? + .split('.') + .nth(/*n*/ 1) + .context("assertion payload")?; + let claims: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload)?, + )?; + assert_eq!(claims["runtime_agent_instance"], "runtime-analytics"); + } + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn analytics_runtime_bootstrap_preserves_sign_in_guidance() -> Result<()> { + skip_if_no_network!(Ok(())); + if std::env::var(PROBE_SERVER).is_ok() { + let (_home, live) = runtime_client( + "https://chatgpt.com/backend-api", + RuntimeAuthMode::DualHeader, + ) + .await?; + assert_eq!( + live.session().await.err().as_deref(), + Some("Sign in again to load Analytics."), + ); + return Ok(()); + } + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/agent/register")) + .respond_with(ResponseTemplate::new(/*s*/ 401).set_body_json(json!({ + "error": {"code": "token_expired"}, + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(/*s*/ 401).set_body_json(json!({ + "error": {"code": "refresh_token_expired"}, + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + run_harness_through_proxy( + &server, + "analytics::client::runtime_auth_tests::analytics_runtime_bootstrap_preserves_sign_in_guidance", + /*realtime_target*/ None, + ).await?; + server.verify().await; + let requests = server + .received_requests() + .await + .context("captured requests")?; + assert!( + requests + .iter() + .any(|request| request.url.path() == "/agent/register") + ); + assert!( + requests + .iter() + .all(|request| { matches!(request.url.path(), "/agent/register" | "/oauth/token") }) + ); + Ok(()) +}