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(()) +}