mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Apply runtime authentication to realtime connections (#60003)
## Why Realtime connections bypassed runtime authentication and reused captured authentication headers for sideband handshakes. They need to honor the selected credential source and its transport policy across call creation, retries, and reconnects. ## What changed - Use runtime authentication for realtime WebSocket connections, WebRTC call creation, and sideband attachment when enabled for supported OpenAI destinations. - Resolve authentication on every WebSocket handshake, preserving credential ownership and request scope while generating fresh assertions. Reject authentication failures without falling back to bearer authentication. - Keep call creation and sideband attachment bound to the selected credentials and account network policy, including when inference uses a different provider. - Avoid runtime initialization for local marketplace and MCP operations, plugin commands that do not use the remote catalog, and model debugging with a configured catalog. ## Testing Add coverage for fresh handshake credentials on retries and reconnects, credential and account changes, runtime authentication modes, destination validation, and registration failures. GitOrigin-RevId: 57e53122912fa78a524427b0f51013f76f9f7681
This commit is contained in:
@@ -251,7 +251,7 @@ async fn get_account_rate_limits_returns_snapshot(
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
"chatgpt_base_url = {server_url:?}\nruntime_auth_mode = \"assertion_only\"\n[features]\nruntime_agent_instances = true\n",
|
||||
"chatgpt_base_url = {server_url:?}\n[features.runtime_agent_instances]\nenabled = true\nmode = \"assertion_only\"\n",
|
||||
),
|
||||
)?;
|
||||
|
||||
|
||||
@@ -2189,9 +2189,10 @@ async fn run_debug_models_command(
|
||||
.await?;
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await?;
|
||||
let runtime_auth = if config
|
||||
.features
|
||||
.enabled(codex_features::Feature::RuntimeAgentInstances)
|
||||
let runtime_auth = if config.model_catalog.is_none()
|
||||
&& config
|
||||
.features
|
||||
.enabled(codex_features::Feature::RuntimeAgentInstances)
|
||||
{
|
||||
let installation_id = codex_core::resolve_installation_id(&config.codex_home).await?;
|
||||
codex_core::build_runtime_auth_manager(
|
||||
|
||||
@@ -5,7 +5,7 @@ use clap::Parser;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::LoaderOverrides;
|
||||
use codex_core::plugins_manager_for_config_with_runtime;
|
||||
use codex_core::plugins_manager_for_config;
|
||||
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
|
||||
use codex_core_plugins::PluginsConfigInput;
|
||||
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
|
||||
@@ -211,9 +211,7 @@ impl JsonMarketplaceAddOutput {
|
||||
}
|
||||
|
||||
async fn run_list(config: Config, args: ListMarketplaceArgs) -> Result<()> {
|
||||
let manager =
|
||||
plugins_manager_for_config_with_runtime(&config, load_cli_auth_manager(&config).await?)
|
||||
.await?;
|
||||
let manager = plugins_manager_for_config(&config, load_cli_auth_manager(&config).await?);
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let marketplace_listing = manager
|
||||
.discover_marketplaces_for_config(&plugins_input, &[])
|
||||
@@ -375,9 +373,7 @@ async fn run_upgrade(
|
||||
marketplace_name,
|
||||
json,
|
||||
} = args;
|
||||
let manager =
|
||||
plugins_manager_for_config_with_runtime(&config, load_cli_auth_manager(&config).await?)
|
||||
.await?;
|
||||
let manager = plugins_manager_for_config(&config, load_cli_auth_manager(&config).await?);
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
let reload_config: codex_core_plugins::ConfigLayerReload = std::sync::Arc::new(move || {
|
||||
|
||||
@@ -19,7 +19,7 @@ use codex_core::config::LoaderOverrides;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_global_mcp_servers;
|
||||
use codex_core::plugins_manager_for_config_with_runtime;
|
||||
use codex_core::plugins_manager_for_config;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::RouteAwareHttpClient;
|
||||
@@ -48,7 +48,6 @@ use codex_utils_redacted_string::RedactedString;
|
||||
use crate::cloud_config;
|
||||
use crate::mcp_login::McpLoginMode;
|
||||
use crate::mcp_login::perform_oauth_login_retry_without_scopes;
|
||||
use crate::plugin_cmd::load_cli_auth_manager;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mcp_cmd_tests.rs"]
|
||||
@@ -533,10 +532,10 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr
|
||||
}
|
||||
|
||||
async fn load_mcp_manager(config: &Config) -> Result<McpManager> {
|
||||
let plugins_manager = Arc::new(
|
||||
plugins_manager_for_config_with_runtime(config, load_cli_auth_manager(config).await?)
|
||||
.await?,
|
||||
);
|
||||
let plugins_manager = Arc::new(plugins_manager_for_config(
|
||||
config,
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await?,
|
||||
));
|
||||
Ok(McpManager::new(plugins_manager))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::plugins_manager_for_config;
|
||||
use codex_core::plugins_manager_for_config_with_runtime;
|
||||
use codex_core_plugins::ConfiguredMarketplace;
|
||||
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
|
||||
@@ -140,13 +141,13 @@ pub async fn run_plugin_add(
|
||||
overrides: Vec<(String, toml::Value)>,
|
||||
args: AddPluginArgs,
|
||||
) -> Result<()> {
|
||||
let context = load_plugin_command_context(overrides).await?;
|
||||
let AddPluginArgs {
|
||||
plugin,
|
||||
marketplace_name,
|
||||
json,
|
||||
} = args;
|
||||
let selection = parse_plugin_selection(plugin, marketplace_name)?;
|
||||
let context = load_plugin_command_context(overrides, Some(&selection.marketplace_name)).await?;
|
||||
let outcome = if selection.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME {
|
||||
let mut listing = fetch_remote_marketplaces(
|
||||
&context,
|
||||
@@ -247,7 +248,7 @@ pub async fn run_plugin_list(
|
||||
overrides: Vec<(String, toml::Value)>,
|
||||
args: ListPluginsArgs,
|
||||
) -> Result<()> {
|
||||
let context = load_plugin_command_context(overrides).await?;
|
||||
let context = load_plugin_command_context(overrides, args.marketplace_name.as_deref()).await?;
|
||||
let remote_listing = fetch_remote_marketplaces(
|
||||
&context,
|
||||
args.marketplace_name.as_deref(),
|
||||
@@ -682,13 +683,13 @@ pub async fn run_plugin_remove(
|
||||
overrides: Vec<(String, toml::Value)>,
|
||||
args: RemovePluginArgs,
|
||||
) -> Result<()> {
|
||||
let context = load_plugin_command_context(overrides).await?;
|
||||
let RemovePluginArgs {
|
||||
plugin,
|
||||
marketplace_name,
|
||||
json,
|
||||
} = args;
|
||||
let selection = parse_plugin_selection(plugin, marketplace_name)?;
|
||||
let context = load_plugin_command_context(overrides, Some(&selection.marketplace_name)).await?;
|
||||
|
||||
if selection.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME {
|
||||
ensure!(
|
||||
@@ -766,6 +767,7 @@ struct PluginCommandContext {
|
||||
|
||||
async fn load_plugin_command_context(
|
||||
overrides: Vec<(String, toml::Value)>,
|
||||
marketplace_name: Option<&str>,
|
||||
) -> Result<PluginCommandContext> {
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let config = Config::load_with_cli_overrides(overrides)
|
||||
@@ -773,14 +775,20 @@ async fn load_plugin_command_context(
|
||||
.context("failed to load configuration")?;
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let auth_manager = load_cli_auth_manager(&config).await?;
|
||||
let manager = Arc::new(
|
||||
plugins_manager_for_config_with_runtime(&config, Arc::clone(&auth_manager)).await?,
|
||||
);
|
||||
let auth = auth_manager.auth().await;
|
||||
let uses_remote_catalog = plugins_input.plugins_enabled
|
||||
&& auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)
|
||||
&& marketplace_name.is_none_or(|name| name == REMOTE_GLOBAL_MARKETPLACE_NAME);
|
||||
let manager = Arc::new(if uses_remote_catalog {
|
||||
plugins_manager_for_config_with_runtime(&config, Arc::clone(&auth_manager)).await?
|
||||
} else {
|
||||
plugins_manager_for_config(&config, Arc::clone(&auth_manager))
|
||||
});
|
||||
Ok(PluginCommandContext {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
plugins_input,
|
||||
manager,
|
||||
auth: auth_manager.auth().await,
|
||||
auth,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -880,6 +880,7 @@ fn contains_transcript_entry(entries: &[RealtimeTranscriptEntry], role: &str, te
|
||||
pub struct RealtimeWebsocketClient {
|
||||
provider: Provider,
|
||||
http_client_factory: HttpClientFactory,
|
||||
auth: Option<crate::SharedAuthProvider>,
|
||||
webrtc_sideband_base_url: String,
|
||||
}
|
||||
|
||||
@@ -895,10 +896,23 @@ impl RealtimeWebsocketClient {
|
||||
Self {
|
||||
provider,
|
||||
http_client_factory,
|
||||
auth: None,
|
||||
webrtc_sideband_base_url: OPENAI_REALTIME_API_BASE_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the transport captured with the selected authentication source.
|
||||
pub fn with_http_client_factory(mut self, factory: HttpClientFactory) -> Self {
|
||||
self.http_client_factory = factory;
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve request auth on every handshake, including sideband retries and reconnects.
|
||||
pub fn with_auth(mut self, auth: crate::SharedAuthProvider) -> Self {
|
||||
self.auth = Some(auth);
|
||||
self
|
||||
}
|
||||
|
||||
/// Overrides the direct WebRTC sideband URL for local development and tests.
|
||||
pub fn with_webrtc_sideband_base_url(mut self, base_url: String) -> Self {
|
||||
self.webrtc_sideband_base_url = base_url;
|
||||
@@ -1088,6 +1102,10 @@ impl RealtimeWebsocketClient {
|
||||
default_headers,
|
||||
);
|
||||
request.headers_mut().extend(headers);
|
||||
if let Some(auth) = &self.auth {
|
||||
let headers = std::mem::take(request.headers_mut());
|
||||
*request.headers_mut() = auth.apply_auth_headers(headers).await?;
|
||||
}
|
||||
|
||||
info!("connecting realtime websocket: {ws_url}");
|
||||
// Realtime websocket TLS should honor the same custom-CA env vars as the rest of Codex's
|
||||
@@ -3264,3 +3282,7 @@ mod tests {
|
||||
server.await.expect("server task");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "methods_auth_tests.rs"]
|
||||
mod auth_tests;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Verifies fresh handshake credentials and rejection after the selected credentials change.
|
||||
|
||||
use super::*;
|
||||
use crate::AuthError;
|
||||
use crate::AuthHeadersFuture;
|
||||
use crate::AuthProvider;
|
||||
use crate::provider::RetryConfig;
|
||||
use http::HeaderName;
|
||||
use http::header::AUTHORIZATION;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::accept_hdr_async;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::Request;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::Response;
|
||||
|
||||
struct HandshakeAuth(AtomicUsize);
|
||||
|
||||
impl AuthProvider for HandshakeAuth {
|
||||
fn add_auth_headers(&self, _headers: &mut HeaderMap) -> Result<(), AuthError> {
|
||||
Err(AuthError::Build(
|
||||
"authentication requires async header resolution".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_auth_headers(&self, mut headers: HeaderMap) -> AuthHeadersFuture<'_> {
|
||||
Box::pin(async move {
|
||||
let attempt = self.0.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
headers.remove("x-openai-agent-assertion");
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
format!("AgentAssertion proof-{attempt}").parse().unwrap(),
|
||||
);
|
||||
Ok(headers)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn client(base_url: String) -> RealtimeWebsocketClient {
|
||||
RealtimeWebsocketClient::new(
|
||||
Provider {
|
||||
name: "test".to_string(),
|
||||
base_url: base_url.clone(),
|
||||
query_params: None,
|
||||
headers: HeaderMap::new(),
|
||||
retry: RetryConfig {
|
||||
max_attempts: 1,
|
||||
base_delay: Duration::from_millis(1),
|
||||
retry_429: false,
|
||||
retry_5xx: true,
|
||||
retry_transport: true,
|
||||
},
|
||||
stream_idle_timeout: Duration::from_secs(5),
|
||||
},
|
||||
codex_http_client::HttpClientFactory::new(
|
||||
codex_http_client::OutboundProxyPolicy::ReqwestDefault,
|
||||
),
|
||||
)
|
||||
.with_webrtc_sideband_base_url(base_url)
|
||||
}
|
||||
|
||||
fn session() -> RealtimeSessionConfig {
|
||||
RealtimeSessionConfig {
|
||||
instructions: String::new(),
|
||||
initial_items: Vec::new(),
|
||||
delegation_ack_filler: None,
|
||||
model: None,
|
||||
session_id: None,
|
||||
event_parser: RealtimeEventParser::V1,
|
||||
session_mode: RealtimeSessionMode::Conversational,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Cedar,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sideband_retry_and_reconnect_replace_cached_auth_headers() -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let base_url = format!("http://{}/v1", listener.local_addr()?);
|
||||
let server = tokio::spawn(async move {
|
||||
let mut requests = Vec::new();
|
||||
for attempt in 0..3 {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let result = accept_hdr_async(stream, |request: &Request, response: Response| {
|
||||
requests.push(request.headers().clone());
|
||||
if attempt == 0 {
|
||||
Err(http::Response::builder().status(503).body(None).unwrap())
|
||||
} else {
|
||||
Ok(response)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if attempt == 0 {
|
||||
assert!(result.is_err());
|
||||
} else {
|
||||
let mut stream = result.unwrap();
|
||||
assert!(matches!(stream.next().await, Some(Ok(Message::Close(_)))));
|
||||
}
|
||||
}
|
||||
requests
|
||||
});
|
||||
let client = client(base_url).with_auth(Arc::new(HandshakeAuth(AtomicUsize::new(0))));
|
||||
let headers = HeaderMap::from_iter([
|
||||
(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer stale-credential"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-openai-agent-assertion"),
|
||||
HeaderValue::from_static("stale-proof"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-request-context"),
|
||||
HeaderValue::from_static("keep"),
|
||||
),
|
||||
]);
|
||||
for _ in 0..2 {
|
||||
let connection = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
client.connect_existing_call_sideband(
|
||||
session(),
|
||||
"rtc_test",
|
||||
headers.clone(),
|
||||
HeaderMap::new(),
|
||||
RealtimeTranscriptState::default(),
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
connection.close().await?;
|
||||
}
|
||||
let requests = tokio::time::timeout(Duration::from_secs(5), server).await??;
|
||||
assert_eq!(requests.len(), 3);
|
||||
for (index, headers) in requests.iter().enumerate() {
|
||||
assert_eq!(
|
||||
headers[AUTHORIZATION],
|
||||
format!("AgentAssertion proof-{}", index + 1)
|
||||
);
|
||||
assert!(!headers.contains_key("x-openai-agent-assertion"));
|
||||
assert_eq!(headers["x-request-context"], "keep");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RejectedAuth;
|
||||
|
||||
impl AuthProvider for RejectedAuth {
|
||||
fn add_auth_headers(&self, _headers: &mut HeaderMap) -> Result<(), AuthError> {
|
||||
Err(AuthError::Build(
|
||||
"authentication requires async header resolution".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_auth_headers(&self, _headers: http::HeaderMap) -> AuthHeadersFuture<'_> {
|
||||
Box::pin(async { Err(AuthError::Build("runtime revoked".to_string())) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_auth_prevents_the_websocket_request() -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let client =
|
||||
client(format!("http://{}/v1", listener.local_addr()?)).with_auth(Arc::new(RejectedAuth));
|
||||
let result = client
|
||||
.connect(session(), HeaderMap::new(), HeaderMap::new())
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), listener.accept())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
+169
-52
@@ -145,9 +145,10 @@ use codex_model_provider::ResolvedProviderRequest as CurrentClientSetup;
|
||||
use codex_model_provider::ResponsesConnectionKey;
|
||||
use codex_model_provider::SharedModelProvider;
|
||||
use codex_model_provider::WorkspaceRoutingContext;
|
||||
use codex_model_provider::create_model_provider;
|
||||
#[cfg(test)]
|
||||
use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::OPENAI_PROVIDER_NAME;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
@@ -561,19 +562,8 @@ pub(crate) struct RealtimeWebrtcCallStart {
|
||||
pub(crate) sdp: String,
|
||||
pub(crate) call_id: String,
|
||||
pub(crate) sideband_headers: ApiHeaderMap,
|
||||
}
|
||||
|
||||
/// Reuses the API-auth material that created the WebRTC call for the sideband WebSocket join.
|
||||
///
|
||||
/// API-key sessions send that API bearer. ChatGPT-auth sessions send their bearer plus account id;
|
||||
/// transceiver is responsible for accepting that same call-create identity on the direct
|
||||
/// `api.openai.com` sideband path.
|
||||
fn sideband_websocket_auth_headers(
|
||||
api_auth: &dyn AuthProvider,
|
||||
) -> std::result::Result<ApiHeaderMap, ApiError> {
|
||||
let mut headers = ApiHeaderMap::new();
|
||||
api_auth.add_auth_headers(&mut headers)?;
|
||||
Ok(headers)
|
||||
pub(crate) sideband_auth: SharedAuthProvider,
|
||||
pub(crate) sideband_http_client_factory: HttpClientFactory,
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
@@ -815,53 +805,195 @@ impl ModelClient {
|
||||
activated
|
||||
}
|
||||
|
||||
async fn realtime_client_setup(
|
||||
&self,
|
||||
auth_context: &codex_model_provider::ProviderAuthContext,
|
||||
http_client_factory: HttpClientFactory,
|
||||
base_url: Option<&str>,
|
||||
headers: Option<&ApiHeaderMap>,
|
||||
call_setup: Option<&CurrentClientSetup>,
|
||||
) -> Result<CurrentClientSetup> {
|
||||
self.validate_required_cyber_access_program(/*programs*/ None)?;
|
||||
// Realtime uses the OpenAI-compatible API protocol independently of the inference transport.
|
||||
let mut info = ModelProviderInfo {
|
||||
name: OPENAI_PROVIDER_NAME.to_owned(),
|
||||
aws: None,
|
||||
..self.state.provider.info().clone()
|
||||
};
|
||||
if let Some(headers) = headers {
|
||||
info.http_headers = Some(
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.map(|value| (name.to_string(), value.to_string().into()))
|
||||
.map_err(|_| CodexErr::InvalidRequest(format!("invalid {name} header")))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
);
|
||||
info.env_http_headers = None;
|
||||
}
|
||||
if let Some(base_url) = base_url {
|
||||
let mut url = url::Url::parse(base_url).map_err(|error| {
|
||||
CodexErr::InvalidRequest(format!("invalid realtime URL: {error}"))
|
||||
})?;
|
||||
if url.scheme() == "wss" {
|
||||
url.set_scheme("https")
|
||||
.map_err(|_| CodexErr::InvalidRequest("invalid realtime URL".into()))?;
|
||||
}
|
||||
info.base_url = Some(url.to_string());
|
||||
}
|
||||
if info
|
||||
.base_url
|
||||
.as_deref()
|
||||
.is_none_or(codex_runtime_agent_instance::is_openai_api_url)
|
||||
{
|
||||
info.requires_openai_auth = true;
|
||||
}
|
||||
let provider = auth_context.model_provider(info.clone());
|
||||
if let Some(call_setup) = call_setup
|
||||
&& call_setup.runtime_id.is_none()
|
||||
&& provider.runtime_auth().is_none()
|
||||
{
|
||||
// Call creation and its sideband share the captured credentials and account policy.
|
||||
return Ok(CurrentClientSetup {
|
||||
auth: call_setup.auth.clone(),
|
||||
auth_owner: call_setup.auth_owner.clone(),
|
||||
auth_revision: call_setup.auth_revision,
|
||||
api_provider: provider.api_provider_for_auth(call_setup.auth.as_ref())?,
|
||||
redirect_policy: call_setup.redirect_policy,
|
||||
api_auth: Arc::clone(&call_setup.api_auth),
|
||||
agent_identity_telemetry: call_setup.agent_identity_telemetry.clone(),
|
||||
runtime_id: None,
|
||||
http_client_factory,
|
||||
});
|
||||
}
|
||||
let mut setup = codex_model_provider::resolve_request(
|
||||
provider.as_ref(),
|
||||
Some(ProviderAuthScope {
|
||||
agent_identity_policy: self.agent_identity_policy,
|
||||
session_source: self.state.session_source.clone(),
|
||||
agent_identity_session_fallback: self.state.agent_identity_session_fallback.clone(),
|
||||
}),
|
||||
/*routing*/ None,
|
||||
http_client_factory,
|
||||
)
|
||||
.await?;
|
||||
if let (Some((runtime, mode)), Some(expected_auth)) = (
|
||||
provider.runtime_auth(),
|
||||
call_setup.and_then(|setup| setup.auth.as_ref()),
|
||||
) {
|
||||
let credentials = provider.auth_manager();
|
||||
let runtime = codex_model_provider::select_runtime_provider(
|
||||
&info,
|
||||
credentials.as_ref(),
|
||||
runtime,
|
||||
)?;
|
||||
let selected = codex_model_provider::select_runtime_auth_for(
|
||||
&runtime,
|
||||
expected_auth,
|
||||
&setup.api_provider.headers,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| codex_api::map_api_error(error.into()))?;
|
||||
setup.api_auth = provider
|
||||
.compose_auth(codex_model_provider::ResolvedProviderAuth {
|
||||
credential: Some(selected.auth().clone()),
|
||||
auth: selected.clone().into_auth_provider(*mode),
|
||||
agent_identity_telemetry: setup.agent_identity_telemetry.clone(),
|
||||
runtime: Some(selected.clone()),
|
||||
})
|
||||
.await?
|
||||
.auth;
|
||||
setup.auth = Some(selected.auth().clone());
|
||||
setup.auth_revision = Some(selected.change_state().generation);
|
||||
setup.auth_owner = Some(selected.auth_owner().clone());
|
||||
setup.runtime_id = Some(selected.runtime().id().to_string());
|
||||
setup.http_client_factory = selected
|
||||
.bind_http_client_factory(setup.http_client_factory)
|
||||
.map_err(|error| codex_api::map_api_error(error.into()))?;
|
||||
}
|
||||
Ok(setup)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_realtime_call_with_headers(
|
||||
&self,
|
||||
sdp: String,
|
||||
session_config: ApiRealtimeSessionConfig,
|
||||
mut extra_headers: ApiHeaderMap,
|
||||
api_provider_override: Option<ApiProvider>,
|
||||
sideband_base_url: &str,
|
||||
auth_context: &codex_model_provider::ProviderAuthContext,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> Result<RealtimeWebrtcCallStart> {
|
||||
// Create the media call over HTTP first, then retain matching auth so realtime can attach
|
||||
// the server-side control WebSocket to the call id from that HTTP response.
|
||||
let mut client_setup = self.realtime_client_setup().await?;
|
||||
let mut setup = self
|
||||
.realtime_client_setup(
|
||||
auth_context,
|
||||
http_client_factory,
|
||||
api_provider_override
|
||||
.as_ref()
|
||||
.map(|provider| provider.base_url.as_str()),
|
||||
api_provider_override
|
||||
.as_ref()
|
||||
.map(|provider| &provider.headers),
|
||||
/*call_setup*/ None,
|
||||
)
|
||||
.await?;
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
let mut sideband_headers = extra_headers.clone();
|
||||
sideband_headers.extend(
|
||||
sideband_websocket_auth_headers(client_setup.api_auth.as_ref())
|
||||
.map_err(|error| self.state.provider.map_api_error(error))?,
|
||||
);
|
||||
if let Some(api_provider) = api_provider_override {
|
||||
client_setup.api_provider = api_provider;
|
||||
setup.api_provider = api_provider;
|
||||
}
|
||||
let transport = client_setup.http_transport(REALTIME_CALLS_ENDPOINT)?;
|
||||
let response =
|
||||
ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth)
|
||||
.create_with_session_and_headers(sdp, session_config, extra_headers)
|
||||
.await
|
||||
.map_err(|error| self.state.provider.map_api_error(error))?;
|
||||
let sideband = self
|
||||
.realtime_client_setup(
|
||||
auth_context,
|
||||
setup.http_client_factory.clone(),
|
||||
Some(sideband_base_url),
|
||||
Some(&setup.api_provider.headers),
|
||||
Some(&setup),
|
||||
)
|
||||
.await?;
|
||||
let transport = setup.http_transport(REALTIME_CALLS_ENDPOINT)?;
|
||||
let response = ApiRealtimeCallClient::new(transport, setup.api_provider, setup.api_auth)
|
||||
.create_with_session_and_headers(sdp, session_config, extra_headers.clone())
|
||||
.await
|
||||
.map_err(|error| self.state.provider.map_api_error(error))?;
|
||||
let mut sideband_headers = sideband.api_provider.headers;
|
||||
sideband_headers.extend(extra_headers);
|
||||
Ok(RealtimeWebrtcCallStart {
|
||||
sdp: response.sdp,
|
||||
call_id: response.call_id,
|
||||
sideband_headers,
|
||||
sideband_auth: sideband.api_auth,
|
||||
sideband_http_client_factory: sideband.http_client_factory,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn realtime_sideband_headers(
|
||||
pub(crate) async fn realtime_sideband_auth(
|
||||
&self,
|
||||
mut extra_headers: ApiHeaderMap,
|
||||
) -> Result<ApiHeaderMap> {
|
||||
let client_setup = self.realtime_client_setup().await?;
|
||||
base_url: &str,
|
||||
auth_context: &codex_model_provider::ProviderAuthContext,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> Result<(SharedAuthProvider, ApiHeaderMap, HttpClientFactory)> {
|
||||
let setup = self
|
||||
.realtime_client_setup(
|
||||
auth_context,
|
||||
http_client_factory,
|
||||
Some(base_url),
|
||||
/*headers*/ None,
|
||||
/*call_setup*/ None,
|
||||
)
|
||||
.await?;
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
extra_headers.extend(
|
||||
sideband_websocket_auth_headers(client_setup.api_auth.as_ref())
|
||||
.map_err(|error| self.state.provider.map_api_error(error))?,
|
||||
);
|
||||
Ok(extra_headers)
|
||||
let mut headers = setup.api_provider.headers;
|
||||
headers.extend(extra_headers);
|
||||
Ok((setup.api_auth, headers, setup.http_client_factory))
|
||||
}
|
||||
|
||||
/// Builds memory summaries for each provided normalized raw memory.
|
||||
@@ -1216,21 +1348,6 @@ impl ModelClient {
|
||||
.await
|
||||
}
|
||||
|
||||
// Realtime keeps provider auth until its runtime integration lands later in the stack.
|
||||
async fn realtime_client_setup(&self) -> Result<CurrentClientSetup> {
|
||||
self.validate_required_cyber_access_program(/*programs*/ None)?;
|
||||
let provider = if self.state.provider.runtime_auth().is_some() {
|
||||
create_model_provider(
|
||||
self.state.provider.info().clone(),
|
||||
self.state.provider.auth_manager(),
|
||||
)
|
||||
} else {
|
||||
Arc::clone(&self.state.provider)
|
||||
};
|
||||
self.provider_client_setup(ClientRouting::ConfiguredProvider, &provider)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn provider_client_setup(
|
||||
&self,
|
||||
routing: ClientRouting,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Exercise selected transport revocation and Realtime compatibility with runtime auth enabled.
|
||||
//! Exercise selected transport revocation with runtime auth enabled.
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
@@ -133,92 +133,6 @@ async fn selected_runtime_transport_stays_revoked(mode: RuntimeAuthMode) {
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[test_case::test_case(RuntimeAuthMode::DualHeader; "dual_header")]
|
||||
#[test_case::test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")]
|
||||
#[tokio::test]
|
||||
async fn realtime_keeps_provider_auth_until_runtime_integration(mode: RuntimeAuthMode) {
|
||||
let (mut client, _) = model_client_with_counting_attestation(/*include_attestation*/ true);
|
||||
let registration = MockServer::start().await;
|
||||
let manager = client.auth_manager().unwrap();
|
||||
let expected_authorization = format!(
|
||||
"Bearer {}",
|
||||
manager.auth().await.unwrap().get_token().unwrap()
|
||||
);
|
||||
let runtime = Arc::new(RuntimeAuthManager::new(
|
||||
manager.clone(),
|
||||
AgentHostAuthorization::InstallationId {
|
||||
installation_id: uuid::Uuid::nil(),
|
||||
},
|
||||
RuntimeRegistrationMetadata {
|
||||
harness: None,
|
||||
harness_version: None,
|
||||
role: RuntimeRole::OrchestratorAndExecutor,
|
||||
},
|
||||
format!("{}/register", registration.uri()).parse().unwrap(),
|
||||
));
|
||||
let info = client.state.provider.info().clone();
|
||||
Arc::get_mut(&mut client.state).unwrap().provider = codex_model_provider::ProviderAuthContext {
|
||||
credentials: manager,
|
||||
runtime_auth: Some((runtime, mode)),
|
||||
}
|
||||
.model_provider(info);
|
||||
assert!(client.state.provider.runtime_auth().is_some());
|
||||
let sideband = client
|
||||
.realtime_sideband_headers(http::HeaderMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sideband[http::header::AUTHORIZATION],
|
||||
expected_authorization
|
||||
);
|
||||
assert!(!sideband.contains_key("x-openai-agent-assertion"));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/realtime/calls"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(201)
|
||||
.insert_header("location", "/realtime/calls/rtc_test")
|
||||
.set_body_string("answer"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut provider = client.state.provider.api_provider().await.unwrap();
|
||||
provider.base_url = server.uri();
|
||||
let call = client
|
||||
.create_realtime_call_with_headers(
|
||||
"offer".into(),
|
||||
codex_api::RealtimeSessionConfig {
|
||||
instructions: "hi".into(),
|
||||
initial_items: Vec::new(),
|
||||
delegation_ack_filler: None,
|
||||
model: Some("gpt-realtime".into()),
|
||||
session_id: None,
|
||||
event_parser: codex_api::RealtimeEventParser::V1,
|
||||
session_mode: codex_api::RealtimeSessionMode::Conversational,
|
||||
output_modality: codex_protocol::protocol::RealtimeOutputModality::Audio,
|
||||
voice: codex_protocol::protocol::RealtimeVoice::Cove,
|
||||
},
|
||||
http::HeaderMap::new(),
|
||||
Some(provider),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(call.call_id.as_str(), call.sdp.as_str()),
|
||||
("rtc_test", "answer")
|
||||
);
|
||||
assert_eq!(
|
||||
call.sideband_headers[http::header::AUTHORIZATION],
|
||||
expected_authorization
|
||||
);
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].headers["authorization"], expected_authorization);
|
||||
assert!(!requests[0].headers.contains_key("x-openai-agent-assertion"));
|
||||
assert!(registration.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test_case::test_case(RuntimeAuthMode::DualHeader; "dual_header")]
|
||||
#[test_case::test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")]
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2256,13 +2256,219 @@ async fn websocket_handshake_includes_attestation_for_chatgpt_codex_responses(
|
||||
assert_eq!(headers["thread-id"], responses_metadata.thread_id);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RealtimeCredentialChange {
|
||||
None,
|
||||
AccountBeforeConnect,
|
||||
TokenDuringAttestation,
|
||||
}
|
||||
|
||||
#[test_case::test_case(RealtimeCredentialChange::None; "same account")]
|
||||
#[test_case::test_case(RealtimeCredentialChange::AccountBeforeConnect; "account changed before connection")]
|
||||
#[test_case::test_case(RealtimeCredentialChange::TokenDuringAttestation; "token changed before sideband setup")]
|
||||
#[tokio::test]
|
||||
async fn realtime_call_preserves_captured_account_binding(change: RealtimeCredentialChange) {
|
||||
use codex_http_client::DestinationPolicy;
|
||||
use codex_http_client::NetworkPolicyController;
|
||||
use codex_login::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::AuthRouteConfig;
|
||||
use codex_login::auth::login_with_chatgpt_auth_tokens;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RefreshDuringAttestation {
|
||||
credentials: Arc<AuthManager>,
|
||||
home: std::path::PathBuf,
|
||||
token: String,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl AttestationProvider for RefreshDuringAttestation {
|
||||
fn header_for_request(
|
||||
&self,
|
||||
_context: AttestationContext,
|
||||
) -> GenerateAttestationFuture<'_> {
|
||||
Box::pin(async move {
|
||||
login_with_chatgpt_auth_tokens(
|
||||
&self.home,
|
||||
&self.token,
|
||||
"workspace-a",
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
self.credentials.reload().await;
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let account_changed = change == RealtimeCredentialChange::AccountBeforeConnect;
|
||||
let home = TempDir::new().unwrap();
|
||||
let controller = NetworkPolicyController::default();
|
||||
let policy = controller.policy();
|
||||
let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)
|
||||
.with_network_policy(policy.clone());
|
||||
let token = |workspace: &str, token_id: &str| {
|
||||
let claims = json!({
|
||||
"jti": token_id,
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_user_id": "realtime-user",
|
||||
"chatgpt_account_id": workspace,
|
||||
}
|
||||
});
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
|
||||
format!("header.{payload}.signature")
|
||||
};
|
||||
let login = |workspace: &str| {
|
||||
let token = token(workspace, "initial");
|
||||
login_with_chatgpt_auth_tokens(
|
||||
home.path(),
|
||||
&token,
|
||||
workspace,
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
token
|
||||
};
|
||||
let access_token = login("workspace-a");
|
||||
let credentials = AuthManager::shared(
|
||||
home.path().to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::Ephemeral,
|
||||
/*forced_chatgpt_workspace_id*/ None,
|
||||
/*chatgpt_base_url*/ None,
|
||||
AuthKeyringBackendKind::default(),
|
||||
AuthRouteConfig::from_http_client_factory(factory.clone()),
|
||||
)
|
||||
.await;
|
||||
let captured_factory = credentials.http_client_factory();
|
||||
if account_changed {
|
||||
login("workspace-b");
|
||||
credentials.reload().await;
|
||||
assert_eq!(
|
||||
credentials
|
||||
.auth_cached()
|
||||
.unwrap()
|
||||
.get_account_id()
|
||||
.as_deref(),
|
||||
Some("workspace-b")
|
||||
);
|
||||
}
|
||||
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/realtime/calls"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(201)
|
||||
.insert_header("location", "/v1/realtime/calls/rtc_bound")
|
||||
.set_body_string("answer-sdp"),
|
||||
)
|
||||
.expect(usize::from(!account_changed) as u64)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let info = ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri())));
|
||||
let api_provider = info
|
||||
.to_api_provider(Some(AuthMode::ChatgptAuthTokens))
|
||||
.unwrap();
|
||||
let mut model_client = test_model_client(SessionSource::Exec);
|
||||
Arc::get_mut(&mut model_client.state).unwrap().provider =
|
||||
create_model_provider(info, Some(Arc::clone(&credentials)));
|
||||
model_client.http_client_factory = factory;
|
||||
let refreshes = Arc::new(AtomicUsize::new(0));
|
||||
let refreshed_token = token("workspace-a", "refreshed");
|
||||
if change == RealtimeCredentialChange::TokenDuringAttestation {
|
||||
let state = Arc::get_mut(&mut model_client.state).unwrap();
|
||||
state.include_attestation = true;
|
||||
state.attestation_provider = Some(Arc::new(RefreshDuringAttestation {
|
||||
credentials: Arc::clone(&credentials),
|
||||
home: home.path().to_path_buf(),
|
||||
token: refreshed_token.clone(),
|
||||
calls: Arc::clone(&refreshes),
|
||||
}));
|
||||
}
|
||||
let context = codex_model_provider::ProviderAuthContext {
|
||||
credentials,
|
||||
runtime_auth: None,
|
||||
};
|
||||
let result = model_client
|
||||
.create_realtime_call_with_headers(
|
||||
"offer-sdp".into(),
|
||||
codex_api::RealtimeSessionConfig {
|
||||
instructions: "hi".into(),
|
||||
initial_items: Vec::new(),
|
||||
delegation_ack_filler: None,
|
||||
model: Some("gpt-realtime".into()),
|
||||
session_id: Some("account-bound".into()),
|
||||
event_parser: codex_api::RealtimeEventParser::V1,
|
||||
session_mode: codex_api::RealtimeSessionMode::Conversational,
|
||||
output_modality: codex_protocol::protocol::RealtimeOutputModality::Audio,
|
||||
voice: codex_protocol::protocol::RealtimeVoice::Cove,
|
||||
},
|
||||
http::HeaderMap::new(),
|
||||
Some(api_provider),
|
||||
&format!("{}/v1", server.uri()),
|
||||
&context,
|
||||
captured_factory,
|
||||
)
|
||||
.await;
|
||||
if account_changed {
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a captured account cannot be replaced before connecting"
|
||||
);
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
} else {
|
||||
let call = result.unwrap();
|
||||
if change == RealtimeCredentialChange::TokenDuringAttestation {
|
||||
assert_eq!(refreshes.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(
|
||||
context
|
||||
.credentials
|
||||
.auth_cached()
|
||||
.unwrap()
|
||||
.get_token()
|
||||
.unwrap(),
|
||||
refreshed_token
|
||||
);
|
||||
}
|
||||
assert_eq!(call.call_id, "rtc_bound");
|
||||
assert_eq!(call.sdp, "answer-sdp");
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].headers["authorization"],
|
||||
format!("Bearer {access_token}")
|
||||
);
|
||||
let sideband_headers = call
|
||||
.sideband_auth
|
||||
.apply_auth_headers(call.sideband_headers)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sideband_headers["authorization"],
|
||||
format!("Bearer {access_token}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_call_sideband_headers_include_attestation() {
|
||||
let (model_client, attestation_calls) =
|
||||
model_client_with_counting_attestation(/*include_attestation*/ true);
|
||||
|
||||
let headers = model_client
|
||||
.realtime_sideband_headers(http::HeaderMap::new())
|
||||
let context = codex_model_provider::ProviderAuthContext {
|
||||
credentials: model_client.auth_manager().unwrap(),
|
||||
runtime_auth: None,
|
||||
};
|
||||
let (_, headers, _) = model_client
|
||||
.realtime_sideband_auth(
|
||||
http::HeaderMap::new(),
|
||||
"https://api.openai.com/v1",
|
||||
&context,
|
||||
model_client.http_client_factory.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("existing call sideband headers should build");
|
||||
|
||||
@@ -2275,6 +2481,324 @@ async fn existing_call_sideband_headers_include_attestation() {
|
||||
assert_eq!(attestation_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
fn realtime_auth_context(
|
||||
model_client: &ModelClient,
|
||||
registration: &wiremock::MockServer,
|
||||
mode: RuntimeAuthMode,
|
||||
) -> codex_model_provider::ProviderAuthContext {
|
||||
let runtime = Arc::new(RuntimeAuthManager::new(
|
||||
model_client.auth_manager().unwrap(),
|
||||
AgentHostAuthorization::InstallationId {
|
||||
installation_id: uuid::Uuid::nil(),
|
||||
},
|
||||
RuntimeRegistrationMetadata {
|
||||
harness: None,
|
||||
harness_version: None,
|
||||
role: RuntimeRole::OrchestratorAndExecutor,
|
||||
},
|
||||
format!("{}/register", registration.uri()).parse().unwrap(),
|
||||
));
|
||||
codex_model_provider::ProviderAuthContext {
|
||||
credentials: model_client.auth_manager().unwrap(),
|
||||
runtime_auth: Some((runtime, mode)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test_case::test_case(RuntimeAuthMode::DualHeader, false; "dual_header")]
|
||||
#[test_case::test_case(RuntimeAuthMode::AssertionOnly, false; "assertion_only")]
|
||||
#[test_case::test_case(RuntimeAuthMode::DualHeader, true; "dual_header_with_gateway")]
|
||||
#[test_case::test_case(RuntimeAuthMode::AssertionOnly, true; "assertion_only_with_gateway")]
|
||||
#[tokio::test]
|
||||
async fn realtime_auth_uses_request_scope_on_reconnect(mode: RuntimeAuthMode, gateway: bool) {
|
||||
let (mut model_client, _) =
|
||||
model_client_with_counting_attestation(/*include_attestation*/ false);
|
||||
let registration = wiremock::MockServer::start().await;
|
||||
let home = TempDir::new().unwrap();
|
||||
let credentials = AuthManager::from_auth_for_testing_with_home(
|
||||
CodexAuth::from_api_key("realtime-key"),
|
||||
home.path().to_path_buf(),
|
||||
);
|
||||
let mut info = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
|
||||
if gateway {
|
||||
info.gateway_oauth = Some(codex_model_provider_info::GatewayOAuthConfig::OAuth(
|
||||
codex_model_provider_info::GatewayOAuthSettings {
|
||||
authorization_url: format!("{}/authorize", registration.uri()),
|
||||
device_authorization_url: None,
|
||||
token_url: format!("{}/token", registration.uri()),
|
||||
client_id: "realtime-client".into(),
|
||||
resource: None,
|
||||
scopes: vec![],
|
||||
redirect_port: None,
|
||||
credentials_store: Default::default(),
|
||||
delivery: codex_model_provider_info::GatewayOAuthDelivery::Header {
|
||||
name: "x-gateway-auth".into(),
|
||||
scheme: "Bearer".into(),
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
let _gateway = gateway.then(|| {
|
||||
codex_model_provider::test_support::seed_gateway_auth(
|
||||
&info,
|
||||
&credentials,
|
||||
json!({"access_token": "gateway-token", "refresh_token": "refresh-secret", "expires_at": i64::MAX}),
|
||||
)
|
||||
});
|
||||
Arc::get_mut(&mut model_client.state).unwrap().provider =
|
||||
create_model_provider(info, Some(credentials));
|
||||
let runtime_id = uuid::Uuid::new_v4().to_string();
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::body_partial_json(serde_json::json!({
|
||||
"actor_auth": {
|
||||
"type": "platform_api_key",
|
||||
"credential": "realtime-key",
|
||||
"project_id": "project-selected"
|
||||
}
|
||||
})))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"runtime_agent_instance_id": runtime_id,
|
||||
"agent_host_id": "host-a",
|
||||
"actor": {
|
||||
"kind": "platform",
|
||||
"user_id": "user-a",
|
||||
"organization_id": "org-a",
|
||||
"project_id": "project-selected"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(®istration)
|
||||
.await;
|
||||
let context = realtime_auth_context(&model_client, ®istration, mode);
|
||||
let headers = http::HeaderMap::from_iter([(
|
||||
http::HeaderName::from_static("openai-project"),
|
||||
http::HeaderValue::from_static("project-selected"),
|
||||
)]);
|
||||
let setup = model_client
|
||||
.realtime_client_setup(
|
||||
&context,
|
||||
model_client.http_client_factory.clone(),
|
||||
Some("wss://api.openai.com/v1"),
|
||||
Some(&headers),
|
||||
/*call_setup*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(setup.runtime_id.as_deref(), Some(runtime_id.as_str()));
|
||||
let sideband = model_client
|
||||
.realtime_client_setup(
|
||||
&context,
|
||||
setup.http_client_factory.clone(),
|
||||
Some("wss://api.openai.com/v1"),
|
||||
Some(&headers),
|
||||
Some(&setup),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sideband.runtime_id, setup.runtime_id);
|
||||
let mut proofs = std::collections::HashSet::new();
|
||||
for attempt in [&setup, &sideband, &sideband] {
|
||||
let headers = attempt
|
||||
.api_auth
|
||||
.apply_auth_headers(attempt.api_provider.headers.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(headers["openai-project"], "project-selected");
|
||||
assert_eq!(
|
||||
headers.get("x-gateway-auth"),
|
||||
gateway.then_some(&http::HeaderValue::from_static("Bearer gateway-token"))
|
||||
);
|
||||
let proof = match mode {
|
||||
RuntimeAuthMode::DualHeader => {
|
||||
assert_eq!(headers["authorization"], "Bearer realtime-key");
|
||||
headers["x-openai-agent-assertion"].clone()
|
||||
}
|
||||
RuntimeAuthMode::AssertionOnly => {
|
||||
assert!(!headers.contains_key("x-openai-agent-assertion"));
|
||||
assert!(
|
||||
headers["authorization"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("AgentAssertion ")
|
||||
);
|
||||
headers["authorization"].clone()
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
proofs.insert(proof),
|
||||
"each attempt must sign a fresh assertion"
|
||||
);
|
||||
}
|
||||
registration.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn realtime_uses_openai_credentials_with_bedrock_inference() {
|
||||
let (mut model_client, _) =
|
||||
model_client_with_counting_attestation(/*include_attestation*/ false);
|
||||
let credentials = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("realtime-key"));
|
||||
let info = ModelProviderInfo::create_amazon_bedrock_provider(Some(
|
||||
codex_model_provider_info::ModelProviderAwsAuthInfo {
|
||||
profile: Some("configured-profile".into()),
|
||||
region: Some("us-east-1".into()),
|
||||
credential_export: None,
|
||||
auth_refresh: None,
|
||||
},
|
||||
));
|
||||
Arc::get_mut(&mut model_client.state).unwrap().provider =
|
||||
create_model_provider(info, Some(Arc::clone(&credentials)));
|
||||
let context = codex_model_provider::ProviderAuthContext {
|
||||
credentials,
|
||||
runtime_auth: None,
|
||||
};
|
||||
|
||||
let (auth, headers, _) = model_client
|
||||
.realtime_sideband_auth(
|
||||
http::HeaderMap::new(),
|
||||
"https://api.openai.com/v1",
|
||||
&context,
|
||||
model_client.http_client_factory.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let headers = auth.apply_auth_headers(headers).await.unwrap();
|
||||
|
||||
assert_eq!(headers["authorization"], "Bearer realtime-key");
|
||||
}
|
||||
|
||||
#[test_case::test_case(RuntimeAuthMode::DualHeader; "dual_header")]
|
||||
#[test_case::test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")]
|
||||
#[tokio::test]
|
||||
async fn realtime_custom_call_keeps_selected_key_for_openai_sideband(mode: RuntimeAuthMode) {
|
||||
let (mut model_client, _) =
|
||||
model_client_with_counting_attestation(/*include_attestation*/ false);
|
||||
let home = TempDir::new().unwrap();
|
||||
let credentials = AuthManager::from_auth_for_testing_with_home(
|
||||
CodexAuth::from_api_key("saved-key"),
|
||||
home.path().to_path_buf(),
|
||||
);
|
||||
let mut info = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
|
||||
info.experimental_bearer_token = Some("realtime-key".into());
|
||||
Arc::get_mut(&mut model_client.state).unwrap().provider =
|
||||
create_model_provider(info, Some(credentials));
|
||||
let registration = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/register"))
|
||||
.and(wiremock::matchers::body_partial_json(json!({
|
||||
"actor_auth": {
|
||||
"type": "platform_api_key",
|
||||
"credential": "realtime-key"
|
||||
}
|
||||
})))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
|
||||
"runtime_agent_instance_id": uuid::Uuid::new_v4().to_string(),
|
||||
"agent_host_id": "host-a",
|
||||
"actor": {
|
||||
"kind": "platform",
|
||||
"user_id": "user-a",
|
||||
"organization_id": "org-a",
|
||||
"project_id": "project-a"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(®istration)
|
||||
.await;
|
||||
let context = realtime_auth_context(&model_client, ®istration, mode);
|
||||
let calls = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/v1/realtime/calls"))
|
||||
.and(wiremock::matchers::header(
|
||||
"authorization",
|
||||
"Bearer realtime-key",
|
||||
))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(201)
|
||||
.insert_header("location", "/v1/realtime/calls/rtc_selected")
|
||||
.set_body_string("answer-sdp"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&calls)
|
||||
.await;
|
||||
let call_provider =
|
||||
ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", calls.uri())))
|
||||
.to_api_provider(Some(AuthMode::ApiKey))
|
||||
.unwrap();
|
||||
|
||||
let call = model_client
|
||||
.create_realtime_call_with_headers(
|
||||
"offer-sdp".into(),
|
||||
codex_api::RealtimeSessionConfig {
|
||||
instructions: "hi".into(),
|
||||
initial_items: Vec::new(),
|
||||
delegation_ack_filler: None,
|
||||
model: Some("gpt-realtime".into()),
|
||||
session_id: Some("selected-key".into()),
|
||||
event_parser: codex_api::RealtimeEventParser::V1,
|
||||
session_mode: codex_api::RealtimeSessionMode::Conversational,
|
||||
output_modality: codex_protocol::protocol::RealtimeOutputModality::Audio,
|
||||
voice: codex_protocol::protocol::RealtimeVoice::Cove,
|
||||
},
|
||||
http::HeaderMap::new(),
|
||||
Some(call_provider),
|
||||
"https://api.openai.com/v1",
|
||||
&context,
|
||||
model_client.http_client_factory.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
(call.call_id.as_str(), call.sdp.as_str()),
|
||||
("rtc_selected", "answer-sdp")
|
||||
);
|
||||
let requests = calls.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(!requests[0].headers.contains_key("x-openai-agent-assertion"));
|
||||
let headers = call
|
||||
.sideband_auth
|
||||
.apply_auth_headers(call.sideband_headers)
|
||||
.await
|
||||
.unwrap();
|
||||
match mode {
|
||||
RuntimeAuthMode::DualHeader => {
|
||||
assert_eq!(headers["authorization"], "Bearer realtime-key");
|
||||
assert!(headers.contains_key("x-openai-agent-assertion"));
|
||||
}
|
||||
RuntimeAuthMode::AssertionOnly => {
|
||||
assert!(
|
||||
headers["authorization"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("AgentAssertion ")
|
||||
);
|
||||
assert!(!headers.contains_key("x-openai-agent-assertion"));
|
||||
}
|
||||
}
|
||||
registration.verify().await;
|
||||
calls.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn realtime_registration_failure_does_not_fall_back_to_bearer_auth() {
|
||||
let (model_client, _) =
|
||||
model_client_with_counting_attestation(/*include_attestation*/ true);
|
||||
let registration = wiremock::MockServer::start().await;
|
||||
let context =
|
||||
realtime_auth_context(&model_client, ®istration, RuntimeAuthMode::AssertionOnly);
|
||||
|
||||
let result = model_client
|
||||
.realtime_sideband_auth(
|
||||
http::HeaderMap::new(),
|
||||
"https://api.openai.com/v1",
|
||||
&context,
|
||||
model_client.http_client_factory.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(registration.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_chatgpt_codex_endpoints_omit_attestation_generation() {
|
||||
let (model_client, attestation_calls) =
|
||||
|
||||
@@ -31,6 +31,7 @@ use codex_api::build_session_headers;
|
||||
use codex_api::map_api_error;
|
||||
use codex_config::config_toml::RealtimeWsMode;
|
||||
use codex_config::config_toml::RealtimeWsVersion;
|
||||
use codex_features::Feature;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::auth::is_fedramp_api_key;
|
||||
@@ -551,6 +552,7 @@ struct RealtimeStart {
|
||||
http_client_factory: HttpClientFactory,
|
||||
realtime_sideband_base_url: Option<String>,
|
||||
extra_headers: Option<HeaderMap>,
|
||||
websocket_auth: Option<codex_api::SharedAuthProvider>,
|
||||
client_managed_handoffs: bool,
|
||||
flush_transcript_tail_on_session_end: bool,
|
||||
codex_responses_as_items: bool,
|
||||
@@ -563,6 +565,7 @@ struct RealtimeStart {
|
||||
model_client: ModelClient,
|
||||
sdp: Option<String>,
|
||||
existing_call_id: Option<String>,
|
||||
auth_context: codex_model_provider::ProviderAuthContext,
|
||||
}
|
||||
|
||||
struct RealtimeStartOutput {
|
||||
@@ -665,10 +668,12 @@ impl RealtimeConversationManager {
|
||||
let session_id = start.session_config.session_id.clone();
|
||||
let RealtimeStart {
|
||||
sideband,
|
||||
auth_context,
|
||||
api_provider,
|
||||
http_client_factory,
|
||||
realtime_sideband_base_url,
|
||||
extra_headers,
|
||||
websocket_auth,
|
||||
client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items,
|
||||
@@ -722,11 +727,10 @@ impl RealtimeConversationManager {
|
||||
audio_rx,
|
||||
};
|
||||
|
||||
let client = RealtimeWebsocketClient::new(api_provider, http_client_factory);
|
||||
let client = match realtime_sideband_base_url {
|
||||
Some(base_url) => client.with_webrtc_sideband_base_url(base_url),
|
||||
None => client,
|
||||
};
|
||||
let sideband_base_url =
|
||||
realtime_sideband_base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
|
||||
let client = RealtimeWebsocketClient::new(api_provider, http_client_factory.clone())
|
||||
.with_webrtc_sideband_base_url(sideband_base_url.clone());
|
||||
let transcript_tail_flush = RealtimeTranscriptTailFlush {
|
||||
enabled: flush_transcript_tail_on_session_end,
|
||||
event_parser,
|
||||
@@ -761,10 +765,15 @@ impl RealtimeConversationManager {
|
||||
session_config.clone(),
|
||||
extra_headers.unwrap_or_default(),
|
||||
realtime_call_api_provider,
|
||||
&sideband_base_url,
|
||||
&auth_context,
|
||||
http_client_factory,
|
||||
)
|
||||
.await?;
|
||||
let task = spawn_webrtc_sideband_input_task(RealtimeWebrtcSidebandInputTask {
|
||||
client,
|
||||
client: client
|
||||
.with_http_client_factory(call.sideband_http_client_factory)
|
||||
.with_auth(call.sideband_auth),
|
||||
session_config,
|
||||
call_id: call.call_id,
|
||||
sideband_headers: call.sideband_headers,
|
||||
@@ -783,6 +792,9 @@ impl RealtimeConversationManager {
|
||||
let task = existing_call::attach(existing_call::ExistingCallAttachment {
|
||||
client,
|
||||
model_client,
|
||||
auth_context,
|
||||
http_client_factory,
|
||||
sideband_base_url,
|
||||
session_config,
|
||||
call_id,
|
||||
extra_headers: extra_headers.unwrap_or_default(),
|
||||
@@ -798,12 +810,13 @@ impl RealtimeConversationManager {
|
||||
.await?;
|
||||
(task, None)
|
||||
} else {
|
||||
let extra_headers = extra_headers.unwrap_or_default();
|
||||
let client = match websocket_auth {
|
||||
Some(auth) => client.with_auth(auth),
|
||||
None => client,
|
||||
};
|
||||
let connection = client
|
||||
.connect(
|
||||
session_config,
|
||||
extra_headers.unwrap_or_default(),
|
||||
default_headers(),
|
||||
)
|
||||
.connect(session_config, extra_headers, default_headers())
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
let task = spawn_realtime_input_task(
|
||||
@@ -1370,6 +1383,7 @@ struct PreparedRealtimeConversationStart {
|
||||
http_client_factory: HttpClientFactory,
|
||||
realtime_sideband_base_url: Option<String>,
|
||||
extra_headers: Option<HeaderMap>,
|
||||
websocket_auth: Option<codex_api::SharedAuthProvider>,
|
||||
client_managed_handoffs: bool,
|
||||
flush_transcript_tail_on_session_end: bool,
|
||||
codex_responses_as_items: bool,
|
||||
@@ -1384,6 +1398,7 @@ struct PreparedRealtimeConversationStart {
|
||||
version: RealtimeWsVersion,
|
||||
session_config: RealtimeSessionConfig,
|
||||
transport: ConversationStartTransport,
|
||||
auth_context: codex_model_provider::ProviderAuthContext,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1406,13 +1421,32 @@ async fn prepare_realtime_start(
|
||||
.auth_manager()
|
||||
.unwrap_or_else(|| Arc::clone(&sess.services.auth_manager));
|
||||
let config = sess.get_config().await;
|
||||
let (auth, http_client_factory) = match auth_manager.auth_with_http_client_factory().await {
|
||||
Some((auth, captured_factory)) if auth.is_chatgpt_auth() || auth.is_api_key_auth() => (
|
||||
Some(auth),
|
||||
config
|
||||
.http_client_factory()
|
||||
.with_network_policy(captured_factory.network_policy().clone()),
|
||||
),
|
||||
let auth_context = codex_model_provider::ProviderAuthContext {
|
||||
credentials: Arc::clone(&auth_manager),
|
||||
runtime_auth: if config.features.enabled(Feature::RuntimeAgentInstances) {
|
||||
let runtime = sess.services.runtime_auth.get().ok_or_else(|| {
|
||||
CodexErr::InvalidRequest("runtime authentication is not initialized".to_string())
|
||||
})?;
|
||||
Some((Arc::clone(runtime), config.runtime_auth_mode))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
let (auth, mut http_client_factory) = match auth_manager.auth_with_http_client_factory().await {
|
||||
Some((auth, captured_factory)) if auth.is_chatgpt_auth() || auth.is_api_key_auth() => {
|
||||
let factory = config.http_client_factory();
|
||||
let policy = factory
|
||||
.network_policy()
|
||||
.clone()
|
||||
.with_account_binding(captured_factory.network_policy())
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(
|
||||
"realtime credentials and transport must share the application network policy"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
(Some(auth), factory.with_network_policy(policy))
|
||||
}
|
||||
Some((auth, _)) => (Some(auth), config.http_client_factory()),
|
||||
None => (None, config.http_client_factory()),
|
||||
};
|
||||
@@ -1487,6 +1521,7 @@ async fn prepare_realtime_start(
|
||||
let requested_realtime_session_id = session_config.session_id.clone();
|
||||
let event_parser = session_config.event_parser;
|
||||
let originator = sess.originator().await;
|
||||
let mut websocket_auth = None;
|
||||
let mut extra_headers = match transport {
|
||||
ConversationStartTransport::Websocket => {
|
||||
let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?;
|
||||
@@ -1496,6 +1531,38 @@ async fn prepare_realtime_start(
|
||||
Some(&CodexAuth::from_api_key(&realtime_api_key)),
|
||||
)?;
|
||||
}
|
||||
if let Some((manager, mode)) = &auth_context.runtime_auth
|
||||
&& crate::runtime_auth::is_openai_realtime_url(&api_provider.base_url)?
|
||||
{
|
||||
codex_model_provider::validate_runtime_environment(
|
||||
manager,
|
||||
Some(&api_provider.base_url),
|
||||
)?;
|
||||
// Saved and command-provided keys keep their existing credential lifecycle.
|
||||
let runtime = if provider.env_key.is_none()
|
||||
&& provider.experimental_bearer_token.is_none()
|
||||
&& auth.as_ref().and_then(CodexAuth::api_key).is_some()
|
||||
{
|
||||
manager.bind(Arc::clone(&auth_manager))
|
||||
} else {
|
||||
manager.bind_api_key(&realtime_api_key, &auth_manager.runtime_config())
|
||||
};
|
||||
let selected = codex_model_provider::select_runtime_auth_for(
|
||||
&runtime,
|
||||
&CodexAuth::from_api_key(&realtime_api_key),
|
||||
&api_provider.headers,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| codex_api::map_api_error(error.into()))?;
|
||||
codex_model_provider::validate_runtime_selectors(
|
||||
&api_provider.headers,
|
||||
selected.runtime().actor().info(),
|
||||
)?;
|
||||
http_client_factory = selected
|
||||
.bind_http_client_factory(http_client_factory)
|
||||
.map_err(|error| codex_api::map_api_error(error.into()))?;
|
||||
websocket_auth = Some(selected.into_auth_provider(*mode));
|
||||
}
|
||||
realtime_request_headers(
|
||||
requested_realtime_session_id.as_deref(),
|
||||
params.voice_session_id.as_deref(),
|
||||
@@ -1530,10 +1597,12 @@ async fn prepare_realtime_start(
|
||||
}
|
||||
Ok(PreparedRealtimeConversationStart {
|
||||
sideband: None,
|
||||
auth_context,
|
||||
api_provider,
|
||||
http_client_factory,
|
||||
realtime_sideband_base_url,
|
||||
extra_headers: Some(extra_headers),
|
||||
websocket_auth,
|
||||
client_managed_handoffs: params.client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end: params.flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items: params.codex_responses_as_items,
|
||||
@@ -1756,10 +1825,12 @@ async fn handle_start_inner(
|
||||
) -> CodexResult<()> {
|
||||
let PreparedRealtimeConversationStart {
|
||||
sideband,
|
||||
auth_context,
|
||||
api_provider,
|
||||
http_client_factory,
|
||||
realtime_sideband_base_url,
|
||||
extra_headers,
|
||||
websocket_auth,
|
||||
client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items,
|
||||
@@ -1788,10 +1859,12 @@ async fn handle_start_inner(
|
||||
let event_parser = session_config.event_parser;
|
||||
let start = RealtimeStart {
|
||||
sideband,
|
||||
auth_context,
|
||||
api_provider,
|
||||
http_client_factory,
|
||||
realtime_sideband_base_url,
|
||||
extra_headers,
|
||||
websocket_auth,
|
||||
client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items,
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_api::RealtimeSessionConfig;
|
||||
use codex_api::RealtimeTranscriptState;
|
||||
use codex_api::RealtimeWebsocketClient;
|
||||
use codex_api::map_api_error;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_login::default_client::default_headers;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use http::HeaderMap;
|
||||
@@ -24,6 +25,9 @@ use tokio_util::sync::CancellationToken;
|
||||
pub(super) struct ExistingCallAttachment {
|
||||
pub(super) client: RealtimeWebsocketClient,
|
||||
pub(super) model_client: ModelClient,
|
||||
pub(super) auth_context: codex_model_provider::ProviderAuthContext,
|
||||
pub(super) http_client_factory: HttpClientFactory,
|
||||
pub(super) sideband_base_url: String,
|
||||
pub(super) session_config: RealtimeSessionConfig,
|
||||
pub(super) call_id: String,
|
||||
pub(super) extra_headers: HeaderMap,
|
||||
@@ -41,6 +45,9 @@ pub(super) async fn attach(attachment: ExistingCallAttachment) -> CodexResult<Jo
|
||||
let ExistingCallAttachment {
|
||||
client,
|
||||
model_client,
|
||||
auth_context,
|
||||
http_client_factory,
|
||||
sideband_base_url,
|
||||
session_config,
|
||||
call_id,
|
||||
extra_headers,
|
||||
@@ -53,9 +60,15 @@ pub(super) async fn attach(attachment: ExistingCallAttachment) -> CodexResult<Jo
|
||||
transcript_tail_flush,
|
||||
stop_token,
|
||||
} = attachment;
|
||||
let sideband_headers = model_client
|
||||
.realtime_sideband_headers(extra_headers)
|
||||
let (auth, sideband_headers, factory) = model_client
|
||||
.realtime_sideband_auth(
|
||||
extra_headers,
|
||||
&sideband_base_url,
|
||||
&auth_context,
|
||||
http_client_factory,
|
||||
)
|
||||
.await?;
|
||||
let client = client.with_http_client_factory(factory).with_auth(auth);
|
||||
let transcript_state = RealtimeTranscriptState::default();
|
||||
let connection = client
|
||||
.connect_existing_call_sideband(
|
||||
|
||||
@@ -15,8 +15,11 @@ use super::realtime_text_from_handoff_request;
|
||||
use super::wrap_realtime_delegation_input;
|
||||
use crate::context::RealtimeDelegationSource;
|
||||
use async_channel::bounded;
|
||||
use base64::Engine;
|
||||
use codex_api::ApiError;
|
||||
use codex_api::RealtimeEventParser;
|
||||
use codex_features::Feature;
|
||||
use codex_features::RuntimeAuthMode;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
@@ -28,13 +31,44 @@ use codex_protocol::protocol::RealtimeConversationVersion;
|
||||
use codex_protocol::protocol::RealtimeHandoffRequested;
|
||||
use codex_protocol::protocol::RealtimeOutputModality;
|
||||
use codex_protocol::protocol::RealtimeTranscriptEntry;
|
||||
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 pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
use test_case::test_case;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
fn websocket_start_params() -> ConversationStartParams {
|
||||
ConversationStartParams {
|
||||
client_managed_handoffs: false,
|
||||
delegation_ack_filler: None,
|
||||
flush_transcript_tail_on_session_end: false,
|
||||
codex_responses_as_items: false,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
|
||||
backend_reasoning_status: false,
|
||||
codex_response_handoff_channel_prefixes: None,
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
include_startup_context: false,
|
||||
initial_items: Vec::new(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
prompt: None,
|
||||
realtime_session_id: None,
|
||||
voice_session_id: None,
|
||||
transport: None,
|
||||
version: Some(RealtimeConversationVersion::V1),
|
||||
voice: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_realtime_routing_follows_selected_api_key_and_explicit_overrides() {
|
||||
for (api_key, base_url, realtime_base_url, expected_url, expected_fedramp_header) in [
|
||||
@@ -82,33 +116,9 @@ async fn prepared_realtime_routing_follows_selected_api_key_and_explicit_overrid
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let prepared = prepare_realtime_start(
|
||||
&sess,
|
||||
ConversationStartParams {
|
||||
client_managed_handoffs: false,
|
||||
delegation_ack_filler: None,
|
||||
flush_transcript_tail_on_session_end: false,
|
||||
codex_responses_as_items: false,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
|
||||
backend_reasoning_status: false,
|
||||
codex_response_handoff_channel_prefixes: None,
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
include_startup_context: false,
|
||||
initial_items: Vec::new(),
|
||||
realtime_start_instructions: None,
|
||||
realtime_end_instructions: None,
|
||||
prompt: None,
|
||||
realtime_session_id: None,
|
||||
voice_session_id: None,
|
||||
transport: None,
|
||||
version: Some(RealtimeConversationVersion::V1),
|
||||
voice: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("realtime startup should prepare without connecting");
|
||||
let prepared = prepare_realtime_start(&sess, websocket_start_params())
|
||||
.await
|
||||
.expect("realtime startup should prepare without connecting");
|
||||
assert_eq!(prepared.api_provider.base_url, expected_url);
|
||||
let headers = prepared.extra_headers.expect("realtime request headers");
|
||||
assert_eq!(
|
||||
@@ -126,6 +136,94 @@ async fn prepared_realtime_routing_follows_selected_api_key_and_explicit_overrid
|
||||
}
|
||||
}
|
||||
|
||||
#[test_case(false; "saved_login")]
|
||||
#[test_case(true; "explicit_key")]
|
||||
#[tokio::test]
|
||||
async fn realtime_reuses_only_the_selected_credential_source(explicit_key: bool) {
|
||||
let registration = wiremock::MockServer::start().await;
|
||||
let registrations = AtomicUsize::new(0);
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/register"))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
let id = registrations.fetch_add(1, Ordering::Relaxed);
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"runtime_agent_instance_id": format!("runtime-{id}"),
|
||||
"agent_host_id": "host-a",
|
||||
"actor": {
|
||||
"kind": "platform",
|
||||
"user_id": "user-a",
|
||||
"organization_id": "org-a",
|
||||
"project_id": null
|
||||
}
|
||||
}))
|
||||
})
|
||||
.expect(if explicit_key { 2 } else { 1 })
|
||||
.mount(®istration)
|
||||
.await;
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let (sess, _turn, _events) =
|
||||
crate::session::tests::make_session_and_context_with_auth_config_home_and_rx(
|
||||
CodexAuth::from_api_key("same-key"),
|
||||
Vec::new(),
|
||||
home.path(),
|
||||
|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::RuntimeAgentInstances)
|
||||
.unwrap();
|
||||
config.runtime_auth_mode = RuntimeAuthMode::AssertionOnly;
|
||||
config.model_provider = ModelProviderInfo {
|
||||
experimental_bearer_token: explicit_key.then(|| "same-key".into()),
|
||||
env_http_headers: None,
|
||||
..ModelProviderInfo::create_openai_provider(/*base_url*/ None)
|
||||
};
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let runtime = Arc::new(RuntimeAuthManager::new(
|
||||
Arc::clone(&sess.services.auth_manager),
|
||||
AgentHostAuthorization::InstallationId {
|
||||
installation_id: uuid::Uuid::nil(),
|
||||
},
|
||||
RuntimeRegistrationMetadata {
|
||||
harness: None,
|
||||
harness_version: None,
|
||||
role: RuntimeRole::Orchestrator,
|
||||
},
|
||||
format!("{}/register", registration.uri()).parse().unwrap(),
|
||||
));
|
||||
sess.services
|
||||
.runtime_auth
|
||||
.set(Arc::clone(&runtime))
|
||||
.unwrap();
|
||||
let login = runtime.auth().await.unwrap();
|
||||
let prepared = prepare_realtime_start(&sess, websocket_start_params())
|
||||
.await
|
||||
.unwrap();
|
||||
let headers = prepared
|
||||
.websocket_auth
|
||||
.expect("runtime auth selected")
|
||||
.apply_auth_headers(http::HeaderMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let assertion = headers[http::header::AUTHORIZATION]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.strip_prefix("AgentAssertion ")
|
||||
.unwrap();
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(assertion.split('.').nth(2).unwrap())
|
||||
.unwrap();
|
||||
let claims: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
let selected_runtime = claims["runtime_agent_instance"].as_str().unwrap();
|
||||
if explicit_key {
|
||||
assert_ne!(selected_runtime, login.runtime().id());
|
||||
} else {
|
||||
assert_eq!(selected_runtime, login.runtime().id());
|
||||
}
|
||||
registration.verify().await;
|
||||
}
|
||||
|
||||
/// Hold old input cleanup open while a successor tries to claim the connection.
|
||||
#[tokio::test]
|
||||
async fn natural_close_finishes_before_successor_and_ignores_old_identity() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use crate::config::Config;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_runtime_agent_instance::RuntimeAuthManager;
|
||||
use codex_runtime_agent_instance::RuntimeRegistrationMetadata;
|
||||
use codex_runtime_agent_instance::RuntimeRole;
|
||||
@@ -51,3 +52,36 @@ pub(crate) fn runtime_for_config(
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_openai_realtime_url(base_url: &str) -> CodexResult<bool> {
|
||||
let Ok(mut url) = url::Url::parse(base_url) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if url.scheme() == "wss" && url.set_scheme("https").is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
codex_model_provider::validate_runtime_destination(Some(url.as_str()))?;
|
||||
Ok(codex_runtime_agent_instance::is_openai_api_url(
|
||||
url.as_str(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_openai_realtime_url;
|
||||
|
||||
#[test]
|
||||
fn realtime_runtime_auth_rejects_unsupported_openai_origins() {
|
||||
for url in [
|
||||
"ws://api.openai.com/v1",
|
||||
"http://api.openai.com/v1",
|
||||
"wss://api.openai.com:8443/v1",
|
||||
"wss://unknown.openai.com/v1",
|
||||
] {
|
||||
assert!(is_openai_realtime_url(url).is_err(), "{url}");
|
||||
}
|
||||
assert!(is_openai_realtime_url("wss://api.openai.com/v1").unwrap());
|
||||
assert!(is_openai_realtime_url("https://api.openai.org/v1").unwrap());
|
||||
assert!(!is_openai_realtime_url("wss://provider.example.com/v1").unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub use request_setup::ResolvedProviderRequest;
|
||||
pub use request_setup::request_auth_owner;
|
||||
pub use request_setup::resolve_request;
|
||||
pub use runtime_auth::runtime_auth_for_url;
|
||||
pub use runtime_auth::select_runtime_auth_for;
|
||||
pub(crate) use runtime_auth::select_runtime_auth_for_source;
|
||||
pub use runtime_auth::select_runtime_provider;
|
||||
pub use runtime_auth::validate_runtime_destination;
|
||||
|
||||
@@ -27,7 +27,7 @@ async fn explicit_provider_token_survives_a_workspace_switch_during_setup() {
|
||||
let provider = create_model_provider(info, Some(manager.clone()));
|
||||
|
||||
let mut attempts = 0;
|
||||
let auth = prepare_provider_request(
|
||||
let resolved = prepare_provider_request(
|
||||
provider.as_ref(),
|
||||
provider_uses_first_party_auth_path(provider.info()),
|
||||
|_| {
|
||||
@@ -41,7 +41,7 @@ async fn explicit_provider_token_survives_a_workspace_switch_during_setup() {
|
||||
/*chatgpt_plan_type*/ None,
|
||||
)?;
|
||||
manager.reload().await;
|
||||
Ok(resolved.auth)
|
||||
Ok(resolved)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -54,7 +54,11 @@ async fn explicit_provider_token_survives_a_workspace_switch_during_setup() {
|
||||
);
|
||||
assert_eq!(attempts, 1);
|
||||
let mut headers = http::HeaderMap::new();
|
||||
auth.add_auth_headers(&mut headers).unwrap();
|
||||
resolved.auth.add_auth_headers(&mut headers).unwrap();
|
||||
assert_eq!(
|
||||
resolved.credential.as_ref().and_then(CodexAuth::api_key),
|
||||
Some("provider-token")
|
||||
);
|
||||
assert_eq!(
|
||||
headers[http::header::AUTHORIZATION],
|
||||
"Bearer provider-token"
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::ModelProvider;
|
||||
use crate::ProviderAuthScope;
|
||||
use crate::ResolvedProviderAuth;
|
||||
use crate::WorkspaceRoutingContext;
|
||||
use crate::auth::bearer_auth_for_provider;
|
||||
use crate::auth::resolve_provider_auth;
|
||||
use crate::auth::resolve_provider_auth_for_scope;
|
||||
use codex_api::AgentIdentityTelemetry;
|
||||
@@ -54,6 +55,13 @@ pub(crate) async fn resolve_request_auth(
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
if let Some(bearer) = bearer_auth_for_provider(&info)? {
|
||||
let credential = bearer.token.as_deref().map(CodexAuth::from_api_key);
|
||||
let mut resolved = ResolvedProviderAuth::new(Arc::new(bearer));
|
||||
resolved.credential = credential;
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
let credential = match &manager {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_http_client::OPENAI_PROJECT_HEADER;
|
||||
use codex_http_client::OpenAiDestination;
|
||||
use codex_http_client::classify_openai_api_url;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
@@ -31,6 +32,18 @@ pub(crate) async fn select_runtime_auth(
|
||||
.map_err(AuthError::from)
|
||||
}
|
||||
|
||||
/// Select the request's resource scope while preserving captured credential ownership.
|
||||
pub async fn select_runtime_auth_for(
|
||||
binding: &Arc<RuntimeAuthBinding>,
|
||||
auth: &CodexAuth,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<RuntimeAuth, AuthError> {
|
||||
binding
|
||||
.auth_for(auth, &resource_selection(headers)?)
|
||||
.await
|
||||
.map_err(AuthError::from)
|
||||
}
|
||||
|
||||
/// Select fresh request auth without allowing a reusable client's source to change.
|
||||
pub(crate) async fn select_runtime_auth_for_source(
|
||||
manager: &Arc<RuntimeAuthBinding>,
|
||||
|
||||
@@ -281,6 +281,8 @@ async fn projects_share_one_runtime_and_organization_changes_replace_it() {
|
||||
selection.project_id = project.map(str::to_owned);
|
||||
let next = manager.auth_with_selection(&selection).await.unwrap();
|
||||
assert!(Arc::ptr_eq(first.runtime(), next.runtime()));
|
||||
let captured = manager.auth_for(first.auth(), &selection).await.unwrap();
|
||||
assert!(Arc::ptr_eq(first.runtime(), captured.runtime()));
|
||||
}
|
||||
selection.organization_id = Some("org-b".into());
|
||||
let other_org = manager.auth_with_selection(&selection).await.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user