From f4275a2a2e6a50d030280439ba161305b324c851 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Sat, 7 Nov 2026 06:22:14 +0000 Subject: [PATCH] Use runtime authentication for remote-control requests (#60073) ## Why Remote-control enrollment, refresh, and client-management requests used ChatGPT bearer authentication without resolving the configured runtime authentication mode. ## What changed Pass the app server's runtime auth slot and mode into remote control, and resolve authentication and HTTP client policy for each request. Supported OpenAI destinations use fresh runtime assertions in `dual_header` or `assertion_only` mode, while custom destinations retain bearer authentication. Wait for runtime authentication to initialize and reject unsupported OpenAI origins or mismatched registered accounts. Pairing continues to use its server token. ## Testing Add transport and app-server integration coverage for both runtime auth modes, fresh assertions, shared runtime registration, network policy enforcement, account validation, redirect credential handling, and pairing token preservation. GitOrigin-RevId: 5401cf565630ab84eaba71ff6040ac27f7b92461 --- codex-rs/Cargo.lock | 5 + codex-rs/app-server-transport/Cargo.toml | 5 + .../src/transport/remote_control/auth.rs | 241 +++++++++++--- .../src/transport/remote_control/clients.rs | 4 +- .../transport/remote_control/controller.rs | 3 +- .../src/transport/remote_control/enroll.rs | 3 +- .../src/transport/remote_control/mod.rs | 7 + .../remote_control/persistence_tests.rs | 7 +- .../remote_control/runtime_auth_tests.rs | 311 ++++++++++++++++++ .../transport/remote_control/server_api.rs | 4 +- .../remote_control/server_api_tests.rs | 3 +- .../src/transport/remote_control/tests.rs | 23 +- .../remote_control/tests/clients_tests.rs | 18 +- .../remote_control/tests/retry_tests.rs | 2 + .../src/transport/remote_control/websocket.rs | 24 +- .../remote_control/websocket_refresh_tests.rs | 2 +- .../remote_control/websocket_retry_tests.rs | 2 +- codex-rs/app-server/src/lib.rs | 3 + .../tests/suite/v2/remote_control_auth.rs | 138 ++++++++ 19 files changed, 735 insertions(+), 70 deletions(-) create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/runtime_auth_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9ca7c05151..04d98c6255 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2331,6 +2331,7 @@ dependencies = [ name = "codex-app-server-transport" version = "0.0.0" dependencies = [ + "anyhow", "axum", "base64 0.22.1", "chrono", @@ -2338,16 +2339,19 @@ dependencies = [ "codex-app-server-protocol", "codex-config", "codex-core", + "codex-features", "codex-http-client", "codex-login", "codex-model-provider", "codex-protocol", + "codex-runtime-agent-instance", "codex-state", "codex-uds", "codex-utils-absolute-path", "codex-utils-rustls-provider", "codex-websocket-auth", "codex-websocket-client", + "core_test_support", "futures", "gethostname", "httpdate", @@ -2367,6 +2371,7 @@ dependencies = [ "tracing", "url", "uuid", + "wiremock", "zstd", ] diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index 95967581f0..d08513df2c 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -25,9 +25,11 @@ base64 = { workspace = true } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } +codex-features = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } +codex-runtime-agent-instance = { workspace = true } codex-state = { workspace = true } codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } @@ -60,6 +62,9 @@ libc = { workspace = true } signal-hook = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } +core_test_support = { workspace = true } +wiremock = { workspace = true } chrono = { workspace = true } codex-config = { workspace = true } pretty_assertions = { workspace = true } 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 a776638b6f..1b6127c419 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 @@ -4,8 +4,11 @@ use axum::http::HeaderMap; use axum::http::HeaderValue; use codex_api::SharedAuthProvider; +use codex_features::RuntimeAuthMode; use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_login::UnauthorizedRecovery; +use codex_runtime_agent_instance::RuntimeAuthSlot; use std::io; use std::io::ErrorKind; use std::sync::Arc; @@ -16,6 +19,7 @@ use tracing::warn; #[derive(Clone)] pub(super) struct RemoteControlAuth { manager: Arc, + runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>, pub(super) owner: crate::ConnectionAuth, pub(super) network_policy: codex_http_client::NetworkPolicy, } @@ -26,7 +30,10 @@ pub(super) struct RemoteControlRecovery { } impl RemoteControlAuth { - pub(super) fn capture(manager: Arc) -> (Self, bool) { + pub(super) fn capture( + manager: Arc, + runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>, + ) -> (Self, bool) { loop { let owner = crate::ConnectionAuth::capture(&manager); let authenticated = manager @@ -37,6 +44,7 @@ impl RemoteControlAuth { Self { network_policy: manager.application_network_policy(), manager, + runtime_auth, owner, }, authenticated, @@ -65,40 +73,88 @@ pub(super) use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER as REMOTE_CONTROL_AC pub(super) struct RemoteControlConnectionAuth { pub(super) http_client_factory: codex_http_client::HttpClientFactory, - pub(super) auth_provider: SharedAuthProvider, + pub(super) auth: CodexAuth, + pub(super) runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>, pub(super) account_id: String, } impl RemoteControlConnectionAuth { - pub(super) fn request_headers(&self) -> io::Result { - let mut headers = HeaderMap::new(); - self.auth_provider - .add_auth_headers(&mut headers) + pub(super) async fn request_context( + &self, + endpoint: &str, + ) -> io::Result<(codex_http_client::HttpClient, HeaderMap)> { + if self.runtime_auth.is_some() { + codex_model_provider::validate_runtime_destination(Some(endpoint)) + .map_err(io::Error::other)?; + } + let runtime = self + .runtime_auth + .as_ref() + .filter(|_| codex_runtime_agent_instance::is_openai_api_url(endpoint)) + .map(|(slot, mode)| { + slot.get() + .cloned() + .map(|manager| (manager, *mode)) + .ok_or_else(|| { + io::Error::new( + ErrorKind::WouldBlock, + "runtime authentication is not initialized", + ) + }) + }) + .transpose()?; + // Keep runtime auth resolution from growing the caller's stack frame. + let resolved = Box::pin(codex_model_provider::auth_provider_for_request( + &self.auth, + runtime.as_ref(), + &HeaderMap::new(), + )) + .await + .map_err(auth_error)?; + let factory = resolved + .bind_http_client_factory(self.http_client_factory.clone()) + .map_err(auth_error)?; + let client = request_client(&factory, endpoint).await?; + let headers = request_headers(&resolved.auth, &self.account_id).await?; + resolved + .validate_headers(&headers) .map_err(io::Error::other)?; - headers.insert( - REMOTE_CONTROL_ACCOUNT_ID_HEADER, - HeaderValue::from_str(&self.account_id).map_err(|err| { - io::Error::new( - ErrorKind::InvalidInput, - format!("invalid remote control account id header: {err}"), - ) - })?, - ); - Ok(headers) + Ok((client, headers)) } } +async fn request_headers( + auth_provider: &SharedAuthProvider, + account_id: &str, +) -> io::Result { + let mut headers = auth_provider + .apply_auth_headers(codex_login::default_client::default_headers()) + .await + .map_err(auth_error)?; + headers.insert( + REMOTE_CONTROL_ACCOUNT_ID_HEADER, + HeaderValue::from_str(account_id).map_err(|err| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control account id header: {err}"), + ) + })?, + ); + Ok(headers) +} + pub(super) async fn load_remote_control_auth( auth: &RemoteControlAuth, ) -> io::Result { auth.ensure_current()?; - let credentials = load_auth_manager(&auth.manager).await?; + let credentials = load_auth_manager(&auth.manager, auth.runtime_auth.clone()).await?; auth.ensure_current()?; Ok(credentials) } async fn load_auth_manager( auth_manager: &Arc, + runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>, ) -> io::Result { let mut reloaded = false; let (auth, http_client_factory) = loop { @@ -134,13 +190,14 @@ async fn load_auth_manager( Ok(RemoteControlConnectionAuth { http_client_factory, - auth_provider: codex_model_provider::auth_provider_from_auth(&auth), + runtime_auth, account_id: auth.get_account_id().ok_or_else(|| { io::Error::new( ErrorKind::WouldBlock, "remote control enrollment is waiting for a ChatGPT account id", ) })?, + auth, }) } @@ -167,6 +224,13 @@ pub(super) fn is_auth_error(error: &io::Error) -> bool { error.kind() == ErrorKind::PermissionDenied && !is_policy_denial(error) } +fn auth_error(error: codex_api::AuthError) -> io::Error { + match error { + codex_api::AuthError::Policy(denied) => io::Error::new(ErrorKind::PermissionDenied, denied), + error => io::Error::other(error), + } +} + pub(super) fn request_error(error: codex_http_client::HttpError) -> io::Error { match error { codex_http_client::HttpError::Policy(denied) => { @@ -254,25 +318,118 @@ mod tests { } } - fn remote_control_auth( + async fn headers_for( account_id: &str, - provider_account_ids: Vec<&'static str>, - ) -> RemoteControlConnectionAuth { - RemoteControlConnectionAuth { - http_client_factory: codex_http_client::HttpClientFactory::new( - codex_http_client::OutboundProxyPolicy::ReqwestDefault, - ), - auth_provider: Arc::new(TestAuthProvider { - account_ids: provider_account_ids, - }), - account_id: account_id.to_string(), - } + account_ids: Vec<&'static str>, + ) -> io::Result { + let provider: SharedAuthProvider = Arc::new(TestAuthProvider { account_ids }); + request_headers(&provider, account_id).await } - #[test] - fn request_headers_adds_account_header_when_provider_omits_it() { - let headers = remote_control_auth("selected-account", Vec::new()) - .request_headers() + #[tokio::test] + async fn runtime_requests_reject_unsupported_openai_origins() -> anyhow::Result<()> { + let credentials = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] { + let (owner, _) = RemoteControlAuth::capture( + Arc::clone(&credentials), + Some((RuntimeAuthSlot::default(), mode)), + ); + let auth = load_remote_control_auth(&owner).await?; + for endpoint in [ + "https://chatgpt.com:8443/backend-api", + "http://chatgpt.com/backend-api", + ] { + assert!( + auth.request_context(endpoint).await.is_err(), + "unsupported OpenAI origins must not fall back to bearer auth" + ); + } + + let (_, headers) = auth + .request_context("https://remote.example/control") + .await?; + assert_eq!( + headers[axum::http::header::AUTHORIZATION], + "Bearer Access Token" + ); + assert_eq!(headers[REMOTE_CONTROL_ACCOUNT_ID_HEADER], "account_id"); + assert!(!headers.contains_key("x-openai-agent-assertion")); + } + Ok(()) + } + + #[tokio::test] + async fn runtime_request_rejects_a_different_registered_account() -> anyhow::Result<()> { + use codex_features::RuntimeAuthMode; + use codex_runtime_agent_instance::AgentHostAuthorization; + use codex_runtime_agent_instance::RuntimeAuthManager; + use codex_runtime_agent_instance::RuntimeRegistrationMetadata; + use codex_runtime_agent_instance::RuntimeRole; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + 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-other-account", "agent_host_id": "host", + "actor": {"kind": "chatgpt", "user_id": "actor", "account_id": "another-account"}, + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let credentials = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let runtime = Arc::new(RuntimeAuthManager::new( + Arc::clone(&credentials), + AgentHostAuthorization::InstallationId { + installation_id: uuid::Uuid::nil(), + }, + RuntimeRegistrationMetadata { + harness: None, + harness_version: None, + role: RuntimeRole::Orchestrator, + }, + format!("{}/agent/register", server.uri()).parse()?, + )); + let slot = RuntimeAuthSlot::default(); + assert!( + slot.set(runtime).is_ok(), + "the runtime slot should be empty" + ); + for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] { + let (owner, _) = RemoteControlAuth::capture( + Arc::clone(&credentials), + Some((Arc::clone(&slot), mode)), + ); + let auth = load_remote_control_auth(&owner).await?; + let Err(error) = auth + .request_context( + "https://chatgpt.com/backend-api/wham/remote/control/server/enroll", + ) + .await + else { + panic!("a different registered account must not authorize enrollment"); + }; + assert!( + error + .to_string() + .contains("chatgpt-account-id does not match") + ); + } + server.verify().await; + Ok(()) + } + + #[tokio::test] + async fn request_headers_adds_account_header_when_provider_omits_it() { + let headers = headers_for("selected-account", Vec::new()) + .await .expect("request headers should build"); assert_eq!( @@ -285,13 +442,13 @@ mod tests { ); } - #[test] - fn request_headers_replaces_provider_accounts_and_preserves_other_headers() { - let headers = remote_control_auth( + #[tokio::test] + async fn request_headers_replaces_provider_accounts_and_preserves_other_headers() { + let headers = headers_for( "selected-account", vec!["provider-account-a", "provider-account-b"], ) - .request_headers() + .await .expect("request headers should build"); assert_eq!( @@ -316,10 +473,10 @@ mod tests { ); } - #[test] - fn request_headers_rejects_invalid_account_header_value() { - let err = remote_control_auth("invalid\naccount", Vec::new()) - .request_headers() + #[tokio::test] + async fn request_headers_rejects_invalid_account_header_value() { + let err = headers_for("invalid\naccount", Vec::new()) + .await .expect_err("invalid account header should fail"); assert_eq!(err.kind(), ErrorKind::InvalidInput); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/clients.rs b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs index 8d33927ee9..4e62d71231 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/clients.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs @@ -2,7 +2,6 @@ use super::auth::RemoteControlAuth; use super::auth::RemoteControlConnectionAuth; use super::auth::load_remote_control_auth; use super::auth::recover_remote_control_auth; -use super::auth::request_client; use super::enroll::format_headers; use super::enroll::preview_remote_control_response_body; use super::protocol::normalize_remote_control_base_url; @@ -190,8 +189,7 @@ async fn send_client_management_request_once( url.as_str() } }; - let client = request_client(&auth.http_client_factory, endpoint).await?; - let auth_headers = auth.request_headers()?; + let (client, auth_headers) = auth.request_context(endpoint).await?; let request = match request { ClientManagementRequest::List { url, params } => { let mut query = Vec::new(); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/controller.rs b/codex-rs/app-server-transport/src/transport/remote_control/controller.rs index e7fb3c3091..fcf5b479bf 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/controller.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/controller.rs @@ -44,7 +44,8 @@ impl RemoteControl { { return current.session.clone(); } - let (auth, authenticated) = RemoteControlAuth::capture(self.auth_manager.clone()); + let (auth, authenticated) = + RemoteControlAuth::capture(self.auth_manager.clone(), self.config.runtime_auth.clone()); let desired = match current.take() { Some(previous) => { previous.session.shutdown_token.cancel(); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index 0b92c6f611..f13a6190fa 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -721,7 +721,8 @@ mod tests { http_client_factory: codex_http_client::HttpClientFactory::new( codex_http_client::OutboundProxyPolicy::ReqwestDefault, ), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), + auth: codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + runtime_auth: None, account_id: "account_id".to_string(), }, "11111111-1111-4111-8111-111111111111", diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index f08a694d9f..0c2c209d64 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -43,7 +43,9 @@ use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_features::RuntimeAuthMode; use codex_login::AuthManager; +use codex_runtime_agent_instance::RuntimeAuthSlot; use codex_state::StateRuntime; use gethostname::gethostname; use std::error::Error; @@ -64,6 +66,7 @@ use tracing::info; use tracing::warn; pub struct RemoteControlStartConfig { + pub runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>, pub remote_control_url: String, pub installation_id: String, pub policy: RemoteControlPolicy, @@ -1005,3 +1008,7 @@ fn same_remote_control_enrollment( mod segment_tests; #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "runtime_auth_tests.rs"] +mod runtime_auth_tests; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/persistence_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/persistence_tests.rs index 01386b19f5..9ffbdb2dca 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/persistence_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/persistence_tests.rs @@ -9,9 +9,10 @@ use tokio::sync::oneshot; #[tokio::test] async fn cancelled_commit_keeps_its_permit_and_is_drained() -> io::Result<()> { - let auth = RemoteControlAuth::capture(auth_manager_from_auth( - CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )) + let auth = RemoteControlAuth::capture( + auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + /*runtime_auth*/ None, + ) .0; let persistence = RemoteControlPersistence::default(); let writes = Arc::new(tokio::sync::Mutex::new(Vec::new())); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/runtime_auth_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/runtime_auth_tests.rs new file mode 100644 index 0000000000..974a7c5b49 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/runtime_auth_tests.rs @@ -0,0 +1,311 @@ +//! Exercise actor-authenticated remote control operations and separate relay capabilities. + +use super::auth::RemoteControlAuth; +use super::auth::load_remote_control_auth; +use super::clients::list_remote_control_clients; +use super::clients::revoke_remote_control_client; +use super::protocol::StartRemoteControlPairingRequest; +use super::protocol::normalize_remote_control_url; +use super::server_api::enroll_remote_control_server; +use super::server_api::refresh_remote_control_server; +use base64::Engine; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_features::RuntimeAuthMode; +use codex_http_client::DestinationPolicy; +use codex_http_client::HttpClientFactory; +use codex_http_client::NetworkPolicyController; +use codex_http_client::OutboundProxyPolicy; +use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; +use codex_login::AuthManager; +use codex_login::AuthRouteConfig; +use codex_login::default_client::ResidencyRequirement; +use codex_login::default_client::set_default_client_residency_requirement; +use codex_runtime_agent_instance::AgentHostAuthorization; +use codex_runtime_agent_instance::RuntimeAuthManager; +use codex_runtime_agent_instance::RuntimeAuthSlot; +use codex_runtime_agent_instance::RuntimeRegistrationMetadata; +use codex_runtime_agent_instance::RuntimeRole; +use core_test_support::runtime_auth::PROBE_SERVER; +use core_test_support::runtime_auth::run_harness_through_proxy; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::HashSet; +use std::sync::Arc; +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 actor_requests_share_runtime_and_pairing_keeps_its_server_token() -> anyhow::Result<()> { + core_test_support::skip_if_no_network!(Ok(())); + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + json!({ + "iss": "https://auth.openai.com/", "session_id": "remote-control-login", + "https://api.openai.com/auth": {"user_id": "actor", "chatgpt_account_id": "account_id"} + }) + .to_string(), + ); + let token = format!("header.{claims}.signature"); + if let Ok(server) = std::env::var(PROBE_SERVER) { + set_default_client_residency_requirement(Some(ResidencyRequirement::Us)); + let home = tempfile::tempdir()?; + codex_login::auth::login_with_chatgpt_auth_tokens( + home.path(), + &token, + "account_id", + /*chatgpt_plan_type*/ None, + )?; + let controller = NetworkPolicyController::default(); + let policy = controller.policy(); + let credentials = Arc::new( + AuthManager::new( + home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + AuthRouteConfig::from_http_client_factory( + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault) + .with_network_policy(policy.clone()), + ), + ) + .await, + ); + let runtime = Arc::new(RuntimeAuthManager::new( + Arc::clone(&credentials), + AgentHostAuthorization::InstallationId { + installation_id: uuid::Uuid::nil(), + }, + RuntimeRegistrationMetadata { + harness: Some("codex".into()), + harness_version: None, + role: RuntimeRole::Orchestrator, + }, + "https://auth.openai.com/agent/register".parse()?, + )); + for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] { + let slot = RuntimeAuthSlot::default(); + let (owner, _) = RemoteControlAuth::capture( + Arc::clone(&credentials), + Some((Arc::clone(&slot), mode)), + ); + let auth = load_remote_control_auth(&owner).await?; + let Err(error) = enroll_remote_control_server( + &normalize_remote_control_url("https://chatgpt.com/backend-api")?, + &auth, + "installation", + "server", + ) + .await + else { + panic!("enrollment requires the harness runtime"); + }; + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + assert!( + slot.set(Arc::clone(&runtime)).is_ok(), + "the harness should fill its empty runtime slot" + ); + controller.unavailable(policy.revision()); + let auth = load_remote_control_auth(&owner).await?; + let Err(error) = enroll_remote_control_server( + &normalize_remote_control_url("https://chatgpt.com/backend-api")?, + &auth, + "installation", + "server", + ) + .await + else { + panic!("managed policy must block the request"); + }; + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert!(super::auth::is_policy_denial(&error)); + assert!(!super::auth::is_auth_error(&error)); + assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); + for base in [ + "https://chatgpt.com/backend-api".to_owned(), + format!("{server}/custom/backend-api"), + ] { + let auth = load_remote_control_auth(&owner).await?; + let mut enrollment = enroll_remote_control_server( + &normalize_remote_control_url(&base)?, + &auth, + "installation", + "server", + ) + .await?; + enrollment.remote_control_token = None; + refresh_remote_control_server(&auth, "installation", &mut enrollment).await?; + let clients = list_remote_control_clients( + &base, + &owner, + RemoteControlClientsListParams { + environment_id: "env".into(), + cursor: None, + limit: None, + order: None, + }, + ) + .await?; + assert!(clients.data.is_empty()); + revoke_remote_control_client( + &base, + &owner, + RemoteControlClientsRevokeParams { + environment_id: "env".into(), + client_id: "client".into(), + }, + ) + .await?; + let pairing = enrollment + .start_pairing( + &auth.http_client_factory, + StartRemoteControlPairingRequest { manual_code: false }, + ) + .await?; + assert_eq!(pairing.pairing_code, "pairing"); + } + } + 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-remote-control", "agent_host_id": "host", + "actor": {"kind": "chatgpt", "user_id": "actor", "account_id": "account_id"} + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + for prefix in ["/backend-api", "/custom/backend-api"] { + for suffix in ["server/enroll", "server/refresh"] { + Mock::given(method("POST")).and(path(format!("{prefix}/wham/remote/control/{suffix}"))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "server_id": "server", "environment_id": "env", "remote_control_token": "relay-capability", "expires_at": "2999-01-01T00:00:00Z" + }))).expect(/*r*/ 2).mount(&server).await; + } + Mock::given(method("GET")) + .and(path(format!( + "{prefix}/wham/remote/control/environments/env/clients" + ))) + .respond_with(ResponseTemplate::new(/*s*/ 307).insert_header( + "location", + format!("{prefix}/wham/remote/control/environments/env/redirected-clients"), + )) + .expect(/*r*/ 2) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!( + "{prefix}/wham/remote/control/environments/env/redirected-clients" + ))) + .respond_with( + ResponseTemplate::new(/*s*/ 200) + .set_body_json(json!({"items": [], "cursor": null})), + ) + .expect(/*r*/ 2) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path(format!( + "{prefix}/wham/remote/control/environments/env/clients/client" + ))) + .respond_with(ResponseTemplate::new(/*s*/ 307).insert_header( + "location", + format!("https://api.openai.org{prefix}/wham/remote/control/environments/env/client-redirected"), + )) + .expect(/*r*/ 2) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path(format!( + "{prefix}/wham/remote/control/environments/env/client-redirected" + ))) + .respond_with(ResponseTemplate::new(/*s*/ 204)) + .expect(/*r*/ 2) + .mount(&server) + .await; + Mock::given(method("POST")).and(path(format!("{prefix}/wham/remote/control/server/pair"))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "server_id": "server", "environment_id": "env", "pairing_code": "pairing", "manual_pairing_code": null, "expires_at": "2999-01-01T00:00:00Z" + }))).expect(/*r*/ 2).mount(&server).await; + } + run_harness_through_proxy(&server, "transport::remote_control::runtime_auth_tests::actor_requests_share_runtime_and_pairing_keeps_its_server_token", /*realtime_target*/ None).await?; + let requests = server.received_requests().await.unwrap(); + let mut proofs = HashSet::new(); + let mut dual = 0; + for request in requests.iter().filter(|r| { + r.url.path().contains("/wham/remote/control/") + && !r.url.path().ends_with("/redirected-clients") + && !r.url.path().ends_with("/client-redirected") + }) { + let authorization = request.headers["authorization"].to_str()?; + if !request.url.path().ends_with("/pair") { + assert_eq!(request.headers["x-openai-internal-codex-residency"], "us"); + } + if request.url.path().ends_with("/pair") { + assert_eq!(authorization, "Bearer relay-capability"); + assert!(!request.headers.contains_key("x-openai-agent-assertion")); + } else if request.url.path().starts_with("/custom/") { + assert_eq!(authorization, format!("Bearer {token}")); + assert!(!request.headers.contains_key("x-openai-agent-assertion")); + } else { + assert_eq!(request.headers["chatgpt-account-id"], "account_id"); + let proof = if let Some(proof) = request.headers.get("x-openai-agent-assertion") { + dual += 1; + assert_eq!(authorization, format!("Bearer {token}")); + proof.to_str()? + } else { + authorization.strip_prefix("AgentAssertion ").unwrap() + }; + let payload: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(proof.split('.').nth(/*n*/ 2).unwrap())?, + )?; + assert_eq!(payload["runtime_agent_instance"], "runtime-remote-control"); + assert!(proofs.insert(proof.to_owned())); + } + } + assert_eq!((dual, proofs.len()), (4, 8)); + for prefix in ["/backend-api", "/custom/backend-api"] { + let original = format!("{prefix}/wham/remote/control/environments/env/clients"); + let redirected = + format!("{prefix}/wham/remote/control/environments/env/redirected-clients"); + let originals: Vec<_> = requests + .iter() + .filter(|r| r.url.path() == original) + .collect(); + let redirects: Vec<_> = requests + .iter() + .filter(|r| r.url.path() == redirected) + .collect(); + assert_eq!(originals.len(), 2); + assert_eq!(redirects.len(), 2); + for (original, redirected) in originals.into_iter().zip(redirects) { + assert_eq!( + redirected.headers.get("authorization"), + original.headers.get("authorization") + ); + assert_eq!( + redirected.headers.get("x-openai-agent-assertion"), + original.headers.get("x-openai-agent-assertion") + ); + } + } + let cross_origin: Vec<_> = requests + .iter() + .filter(|r| r.url.path().ends_with("/client-redirected")) + .collect(); + assert_eq!(cross_origin.len(), 4); + for request in cross_origin { + assert!(!request.headers.contains_key("authorization")); + assert!(!request.headers.contains_key("x-openai-agent-assertion")); + } + server.verify().await; + Ok(()) +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/server_api.rs b/codex-rs/app-server-transport/src/transport/remote_control/server_api.rs index badf35ab4a..1d32b0c0b3 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/server_api.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/server_api.rs @@ -1,5 +1,4 @@ use super::auth::RemoteControlConnectionAuth; -use super::auth::request_client; use super::enroll::RemoteControlEnrollment; use super::enroll::RemoteControlServerTokenRefreshRequirement; use super::enroll::format_headers; @@ -241,8 +240,7 @@ where Request: Serialize, Response: DeserializeOwned, { - let client = request_client(&auth.http_client_factory, url).await?; - let auth_headers = auth.request_headers()?; + let (client, auth_headers) = auth.request_context(url).await?; let response = client .post(url) .timeout(timeout) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/server_api_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/server_api_tests.rs index cc8d93d447..a871b696e8 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/server_api_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/server_api_tests.rs @@ -14,7 +14,8 @@ fn auth() -> RemoteControlConnectionAuth { http_client_factory: codex_http_client::HttpClientFactory::new( codex_http_client::OutboundProxyPolicy::ReqwestDefault, ), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), + auth: codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + runtime_auth: None, account_id: "account-a".to_string(), } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 8cd8793bdf..d00207853e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -235,7 +235,7 @@ async fn plain_start_resolves_persisted_remote_control_preference() { server_name: test_server_name(), }, Some(state_db), - auth::RemoteControlAuth::capture(remote_control_auth_manager()).0, + auth::RemoteControlAuth::capture(remote_control_auth_manager(), /*runtime_auth*/ None).0, RemoteControlChannels { transport_event_tx, status_publisher: RemoteControlStatusPublisher::new(status_tx), @@ -287,6 +287,7 @@ async fn explicit_disabled_start_ignores_persisted_enable() { let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url: TEST_REMOTE_CONTROL_URL.to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -349,6 +350,7 @@ async fn managed_disable_overrides_startup_and_persisted_enablement() { let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::DisabledByRequirements, @@ -474,7 +476,7 @@ pub(super) fn remote_control_handle_with_current_enrollment( current_enrollment, pairing_persistence_key: watch::channel(None).0, pairing_persistence_key_required: false, - auth_manager: auth::RemoteControlAuth::capture(auth_manager).0, + auth_manager: auth::RemoteControlAuth::capture(auth_manager, /*runtime_auth*/ None).0, } } @@ -800,6 +802,7 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages() let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1096,6 +1099,7 @@ async fn remote_control_transport_reconnects_after_disconnect() { let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1199,6 +1203,7 @@ async fn remote_control_transport_refreshes_server_token_after_websocket_unautho let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1281,6 +1286,7 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url: "https://internal.example.com/backend-api/".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1324,6 +1330,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1360,6 +1367,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1420,6 +1428,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1540,6 +1549,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1726,6 +1736,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() { let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -1990,6 +2001,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -2112,6 +2124,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -2199,6 +2212,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -2306,6 +2320,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -2427,6 +2442,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -2552,6 +2568,7 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404() let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -2693,6 +2710,7 @@ async fn remote_control_http_mode_preserves_stale_enrollment_when_reenrollment_f let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -2818,6 +2836,7 @@ async fn remote_control_http_mode_preserves_enrollment_after_generic_websocket_4 let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 1aea56af42..06a448e83a 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -33,7 +33,7 @@ fn client_management_handle( current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)), pairing_persistence_key: watch::channel(None).0, pairing_persistence_key_required: false, - auth_manager: auth::RemoteControlAuth::capture(auth_manager).0, + auth_manager: auth::RemoteControlAuth::capture(auth_manager, /*runtime_auth*/ None).0, } } @@ -225,7 +225,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { let response = list_remote_control_clients( &remote_control_url, - &auth::RemoteControlAuth::capture(auth_manager.clone()).0, + &auth::RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0, RemoteControlClientsListParams { environment_id: "env-123".to_string(), ..Default::default() @@ -323,7 +323,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { let err = list_remote_control_clients( &remote_control_url, - &auth::RemoteControlAuth::capture(auth_manager.clone()).0, + &auth::RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0, RemoteControlClientsListParams { environment_id: "env-123".to_string(), ..Default::default() @@ -363,7 +363,11 @@ async fn revoke_remote_control_client_does_not_retry_forbidden() { let err = revoke_remote_control_client( &remote_control_url, - &auth::RemoteControlAuth::capture(remote_control_auth_manager()).0, + &auth::RemoteControlAuth::capture( + remote_control_auth_manager(), + /*runtime_auth*/ None, + ) + .0, RemoteControlClientsRevokeParams { environment_id: "env-123".to_string(), client_id: "client-123".to_string(), @@ -395,7 +399,11 @@ async fn list_remote_control_clients_preserves_decode_error_context() { let err = list_remote_control_clients( &remote_control_url, - &auth::RemoteControlAuth::capture(remote_control_auth_manager()).0, + &auth::RemoteControlAuth::capture( + remote_control_auth_manager(), + /*runtime_auth*/ None, + ) + .0, RemoteControlClientsListParams { environment_id: "env-123".to_string(), ..Default::default() diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/retry_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/retry_tests.rs index cd262f56fa..7e11a81c6c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/retry_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/retry_tests.rs @@ -49,6 +49,7 @@ async fn assert_overload_retry_after(status: &str, reject_enrollment: bool) { auth_manager.reload().await; let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url: remote_control_url_for_listener(&listener), installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, @@ -212,6 +213,7 @@ async fn reconnect_backoff_covers_refresh_and_resets_after_healthy_connection() let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: None, remote_control_url: remote_control_url_for_listener(&listener), installation_id: TEST_INSTALLATION_ID.to_string(), policy: RemoteControlPolicy::Allowed, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 8daf45e4d0..d5451c6645 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -2166,7 +2166,8 @@ mod tests { let codex_home = TempDir::new().expect("temp dir should create"); let state_db = remote_control_state_runtime(&codex_home).await; let auth_manager = remote_control_auth_manager(); - let session_auth = RemoteControlAuth::capture(auth_manager.clone()).0; + let session_auth = + RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0; let mut auth_recovery = session_auth.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( @@ -2239,7 +2240,8 @@ mod tests { let codex_home = TempDir::new().expect("temp dir should create"); let state_db = remote_control_state_runtime(&codex_home).await; let auth_manager = remote_control_auth_manager(); - let session_auth = RemoteControlAuth::capture(auth_manager.clone()).0; + let session_auth = + RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0; let mut auth_recovery = session_auth.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); let next_refresh_at = time::OffsetDateTime::now_utc() + time::Duration::minutes(2); @@ -2327,7 +2329,8 @@ mod tests { codex_login::test_support::transport_default_auth_route_config(), ) .await; - let session_auth = RemoteControlAuth::capture(auth_manager.clone()).0; + let session_auth = + RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0; let mut auth_recovery = session_auth.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); let current_enrollment = test_current_enrollment(/*enrollment*/ None); @@ -2428,7 +2431,8 @@ mod tests { codex_login::test_support::transport_default_auth_route_config(), ) .await; - let session_auth = RemoteControlAuth::capture(auth_manager.clone()).0; + let session_auth = + RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0; let mut auth_recovery = session_auth.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut expected_enrollment = @@ -2516,7 +2520,8 @@ mod tests { let remote_control_target = normalize_remote_control_url("http://127.0.0.1:9/backend-api/") .expect("target should parse"); let auth_manager = remote_control_auth_manager(); - let session_auth = RemoteControlAuth::capture(auth_manager.clone()).0; + let session_auth = + RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0; let mut auth_recovery = session_auth.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( @@ -2567,7 +2572,8 @@ mod tests { codex_login::test_support::transport_default_auth_route_config(), ) .await; - let session_auth = RemoteControlAuth::capture(auth_manager.clone()).0; + let session_auth = + RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0; let mut auth_recovery = session_auth.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( @@ -2648,7 +2654,11 @@ mod tests { server_name: "test-server".to_string(), }, /*state_db*/ None, - RemoteControlAuth::capture(remote_control_auth_manager()).0, + RemoteControlAuth::capture( + remote_control_auth_manager(), + /*runtime_auth*/ None, + ) + .0, RemoteControlChannels { transport_event_tx, status_publisher, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket_refresh_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket_refresh_tests.rs index 07abe80d17..6409f920cb 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket_refresh_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket_refresh_tests.rs @@ -37,7 +37,7 @@ async fn connect_test_websocket( auth_manager: &Arc, current_enrollment: &CurrentRemoteControlEnrollment, ) -> io::Result<()> { - let session_auth = RemoteControlAuth::capture(auth_manager.clone()).0; + let session_auth = RemoteControlAuth::capture(auth_manager.clone(), /*runtime_auth*/ None).0; let mut auth_recovery = session_auth.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); let (status_publisher, _) = remote_control_status_channel(); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket_retry_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket_retry_tests.rs index b21671c54b..2713b21326 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket_retry_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket_retry_tests.rs @@ -52,7 +52,7 @@ async fn assert_conflict_recovery(subscribe_cursor: Option<&str>) { server_name: "test-server".to_string(), }, Some(state_db), - RemoteControlAuth::capture(remote_control_auth_manager()).0, + RemoteControlAuth::capture(remote_control_auth_manager(), /*runtime_auth*/ None).0, RemoteControlChannels { transport_event_tx, status_publisher, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 57166de44a..b1e4d4e4a8 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -895,6 +895,9 @@ pub async fn run_main_with_transport_options( let (remote_control_accept_handle, remote_control_handle) = start_remote_control( RemoteControlStartConfig { + runtime_auth: runtime_auth_slot + .clone() + .map(|slot| (slot, config.runtime_auth_mode)), remote_control_url: config.chatgpt_base_url.clone(), installation_id: installation_id.clone(), policy: remote_control_policy, diff --git a/codex-rs/app-server/tests/suite/v2/remote_control_auth.rs b/codex-rs/app-server/tests/suite/v2/remote_control_auth.rs index 3ca4250ff8..d0c4c95653 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control_auth.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control_auth.rs @@ -491,3 +491,141 @@ async fn logout_login_enable_recovers_first_unauthorized_enrollment() -> Result< let _websocket = timeout(DEFAULT_TIMEOUT, accept_async(stream)).await??; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn public_client_management_uses_runtime_auth() -> Result<()> { + use base64::Engine; + use core_test_support::runtime_auth::PROBE_SERVER; + use core_test_support::runtime_auth::run_harness_through_proxy; + use std::collections::BTreeSet; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + core_test_support::skip_if_no_network!(Ok(())); + let claims = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({ + "exp": 4102444800_i64, "session_id": "remote-public", + "iss": "https://auth.openai.com", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-123", "chatgpt_account_id": "account-123" + }, + }))?); + let token = format!("e30.{claims}.c2ln"); + if std::env::var(PROBE_SERVER).is_ok() { + for mode in ["dual_header", "assertion_only"] { + let home = TempDir::new()?; + write_chatgpt_auth( + home.path(), + ChatGptAuthFixture::new(&token) + .account_id("account-123") + .chatgpt_user_id("user-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + std::fs::write( + home.path().join("config.toml"), + format!( + "chatgpt_base_url = \"https://chatgpt.com/backend-api\"\n[features.runtime_agent_instances]\nenabled = true\nmode = {mode:?}\n" + ), + )?; + let mut app = TestAppServer::builder() + .with_codex_home(home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + for _ in 0..2 { + let id = app + .send_remote_control_clients_list_request(RemoteControlClientsListParams { + environment_id: "environment".into(), + cursor: None, + limit: None, + order: None, + }) + .await?; + let response: RemoteControlClientsListResponse = + timeout(DEFAULT_TIMEOUT, app.read_response(id)).await??; + assert!(response.data.is_empty()); + } + let id = app + .send_remote_control_clients_revoke_request(RemoteControlClientsRevokeParams { + environment_id: "environment".into(), + client_id: "client".into(), + }) + .await?; + let _: RemoteControlClientsRevokeResponse = + timeout(DEFAULT_TIMEOUT, app.read_response(id)).await??; + } + return Ok(()); + } + let server = MockServer::start().await; + app_test_support::mount_workspace_routing(&server).await; + Mock::given(method("POST")) + .and(path("/agent/register")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "runtime_agent_instance_id": "runtime-remote-public", "agent_host_id": "host", + "actor": {"kind": "chatgpt", "user_id": "user-123", "account_id": "account-123"} + }))) + .expect(/*r*/ 2) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/backend-api/wham/remote/control/environments/environment/clients", + )) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "items": [], "cursor": null + }))) + .expect(/*r*/ 4) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path( + "/backend-api/wham/remote/control/environments/environment/clients/client", + )) + .respond_with(ResponseTemplate::new(/*s*/ 204)) + .expect(/*r*/ 2) + .mount(&server) + .await; + run_harness_through_proxy( + &server, + "suite::v2::remote_control::auth_tests::public_client_management_uses_runtime_auth", + /*realtime_target*/ None, + ) + .await?; + let requests = server.received_requests().await.unwrap(); + let mut proofs = BTreeSet::new(); + let mut dual = 0; + for request in requests + .iter() + .filter(|r| r.url.path().contains("/wham/remote/control/")) + { + assert_eq!(request.headers["host"], "chatgpt.com"); + assert_eq!(request.headers["chatgpt-account-id"], "account-123"); + let authorization = request.headers["authorization"].to_str()?; + let proof = if let Some(proof) = request.headers.get("x-openai-agent-assertion") { + dual += 1; + assert_eq!(authorization, format!("Bearer {token}")); + proof.to_str()? + } else { + authorization + .strip_prefix("AgentAssertion ") + .expect("assertion-only auth") + }; + let payload: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(proof.split('.').nth(/*n*/ 2).unwrap())?, + )?; + assert_eq!(payload["runtime_agent_instance"], "runtime-remote-public"); + assert!( + proofs.insert(proof.to_owned()), + "each request gets a fresh assertion" + ); + } + assert_eq!((dual, proofs.len()), (3, 6)); + server.verify().await; + Ok(()) +}