mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Extend runtime auth to account, plugin, and file requests (#59564)
## What changed - Use the shared runtime and effective configuration for account API clients and remote plugin requests, including standalone plugin commands. - Support dual-header and assertion-only authentication for remote plugins and MCP file uploads when runtime agent instances are enabled. - Keep plugin credentials and caches scoped to the thread's account when resuming with different credentials. Bind request pools and multi-step plugin storage operations to the selected account so account replacement revokes stale operations. - Protect runtime credentials on cross-origin redirects while preserving configured routing policies and legacy authentication for custom backends. - Apply server-provided `upload_headers` to file storage uploads. ## Testing Add coverage for shared runtime registration, both authentication modes, configuration reloads, resumed-thread account selection, account replacement during plugin uploads and downloads, and credential stripping on redirects. Extend file upload tests to verify runtime assertions and storage-only upload headers. GitOrigin-RevId: 68aef81051a6f9bf919fffda52f372b108fce805
This commit is contained in:
@@ -2203,7 +2203,9 @@ respect_system_proxy = true
|
||||
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
|
||||
);
|
||||
assert_eq!(
|
||||
config.plugins_config_input().remote_plugin_service_config(),
|
||||
config
|
||||
.plugins_config_input()
|
||||
.remote_plugin_service_config(/*runtime_auth*/ None),
|
||||
codex_core_plugins::remote::RemotePluginServiceConfig::new(
|
||||
config.chatgpt_base_url,
|
||||
codex_http_client::HttpClientFactory::new(
|
||||
|
||||
@@ -1861,6 +1861,11 @@ impl Config {
|
||||
self.http_client_factory(),
|
||||
self.apps_mcp_product_sku.clone(),
|
||||
)
|
||||
.with_runtime_auth_mode(
|
||||
(self.features.enabled(Feature::RuntimeAgentInstances)
|
||||
&& codex_runtime_agent_instance::is_openai_api_url(&self.chatgpt_base_url))
|
||||
.then_some(self.runtime_auth_mode),
|
||||
)
|
||||
}
|
||||
|
||||
/// Applies managed MCP requirements to servers supplied by one plugin.
|
||||
|
||||
@@ -22,7 +22,7 @@ use tracing::warn;
|
||||
use crate::config::Config;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::plugins::list_tool_suggest_discoverable_plugins;
|
||||
use crate::plugins::plugins_manager_for_config;
|
||||
use crate::plugins::plugins_manager_for_config_with_runtime;
|
||||
use crate::session::INITIAL_SUBMIT_ID;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
@@ -187,10 +187,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
) -> anyhow::Result<AccessibleConnectorsStatus> {
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await?;
|
||||
let plugins_manager = Arc::new(plugins_manager_for_config(
|
||||
config,
|
||||
Arc::clone(&auth_manager),
|
||||
));
|
||||
let plugins_manager =
|
||||
Arc::new(plugins_manager_for_config_with_runtime(config, Arc::clone(&auth_manager)).await?);
|
||||
let mcp_manager = Arc::new(McpManager::new(plugins_manager));
|
||||
list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
config,
|
||||
|
||||
@@ -127,6 +127,7 @@ pub use mention_syntax::TOOL_MENTION_SIGIL;
|
||||
pub use utils::path_utils;
|
||||
pub(crate) mod plugins;
|
||||
pub use plugins::plugins_manager_for_config;
|
||||
pub use plugins::plugins_manager_for_config_with_runtime;
|
||||
#[doc(hidden)]
|
||||
pub(crate) mod prompt_debug;
|
||||
#[doc(hidden)]
|
||||
|
||||
@@ -146,6 +146,16 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_plugins_manager(&self, plugins_manager: Arc<PluginsManager>) -> Self {
|
||||
let mut manager = Self::new_with_extensions(
|
||||
plugins_manager,
|
||||
Arc::clone(&self.extensions),
|
||||
ConnectorRuntimeManager::default(),
|
||||
);
|
||||
manager.connection_pool = self.connection_pool.clone();
|
||||
manager
|
||||
}
|
||||
|
||||
pub fn codex_apps_tools_cache(&self) -> ConnectorRuntimeManager<ToolInfo> {
|
||||
self.codex_apps_tools_cache.clone()
|
||||
}
|
||||
|
||||
@@ -236,11 +236,38 @@ async fn build_uploaded_argument_value(
|
||||
}
|
||||
}
|
||||
};
|
||||
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
|
||||
let runtime = if turn_context
|
||||
.config
|
||||
.features
|
||||
.enabled(codex_features::Feature::RuntimeAgentInstances)
|
||||
&& codex_http_client::classify_openai_api_url(&turn_context.config.chatgpt_base_url)
|
||||
!= codex_http_client::OpenAiDestination::Other
|
||||
{
|
||||
codex_model_provider::validate_runtime_destination(Some(
|
||||
&turn_context.config.chatgpt_base_url,
|
||||
))
|
||||
.map_err(|error| contextualize_error(error.to_string()))?;
|
||||
let manager = sess.services.runtime_auth.get().ok_or_else(|| {
|
||||
contextualize_error("runtime authentication has not been initialized".to_string())
|
||||
})?;
|
||||
Some((Arc::clone(manager), turn_context.config.runtime_auth_mode))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let upload_auth = codex_model_provider::auth_provider_for_request(
|
||||
auth,
|
||||
runtime.as_ref(),
|
||||
&http::HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| contextualize_error(error.to_string()))?;
|
||||
let upload_clients = upload_auth
|
||||
.bind_http_client_pool(&sess.services.openai_file_upload_client_pool)
|
||||
.map_err(|error| contextualize_error(error.to_string()))?;
|
||||
let uploaded = upload_openai_file(
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
||||
upload_auth.as_ref(),
|
||||
&sess.services.openai_file_upload_client_pool,
|
||||
upload_auth.auth.as_ref(),
|
||||
&upload_clients,
|
||||
file_name,
|
||||
metadata.size,
|
||||
open_contents,
|
||||
|
||||
@@ -44,3 +44,39 @@ pub fn plugins_manager_for_config(
|
||||
skill_root_loader,
|
||||
)
|
||||
}
|
||||
|
||||
/// Initialize a standalone command's runtime before it can make first-party plugin requests.
|
||||
/// Callers that own a ThreadManager should reuse that manager's PluginsManager instead.
|
||||
pub async fn plugins_manager_for_config_with_runtime(
|
||||
config: &Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> anyhow::Result<PluginsManager> {
|
||||
if !config
|
||||
.features
|
||||
.enabled(codex_features::Feature::RuntimeAgentInstances)
|
||||
{
|
||||
return Ok(plugins_manager_for_config(config, auth_manager));
|
||||
}
|
||||
codex_model_provider::validate_runtime_destination(Some(&config.chatgpt_base_url))?;
|
||||
if !codex_runtime_agent_instance::is_openai_api_url(&config.chatgpt_base_url) {
|
||||
return Ok(plugins_manager_for_config(config, auth_manager));
|
||||
}
|
||||
let installation_id =
|
||||
crate::installation_id::resolve_installation_id(&config.codex_home).await?;
|
||||
let runtime = crate::runtime_auth::build_runtime_auth_manager(
|
||||
config,
|
||||
Arc::clone(&auth_manager),
|
||||
&installation_id,
|
||||
);
|
||||
let slot = Arc::new(runtime.map(std::sync::OnceLock::from).unwrap_or_default());
|
||||
Ok(PluginsManager::new_with_options(
|
||||
config.codex_home.to_path_buf(),
|
||||
Some(codex_protocol::protocol::Product::Codex),
|
||||
auth_manager,
|
||||
slot,
|
||||
Arc::new(HostSkillsService::new(
|
||||
config.codex_home.clone(),
|
||||
/*bundled_skills_enabled*/ false,
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ enabled = false
|
||||
codex_home.path().to_path_buf(),
|
||||
Some(Product::Codex),
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
codex_runtime_agent_instance::RuntimeAuthSlot::default(),
|
||||
skills_service.clone(),
|
||||
);
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
|
||||
@@ -1925,11 +1925,24 @@ impl Session {
|
||||
exec_policy,
|
||||
auth_manager: Arc::clone(&auth_manager),
|
||||
runtime_auth,
|
||||
openai_file_upload_client_pool: RouteAwareClientPool::new_without_request_logging(
|
||||
config.http_client_factory(),
|
||||
ClientRouteClass::Api,
|
||||
)
|
||||
.with_legacy_custom_ca_fallback(),
|
||||
openai_file_upload_client_pool: if config
|
||||
.features
|
||||
.enabled(Feature::RuntimeAgentInstances)
|
||||
&& codex_runtime_agent_instance::is_openai_api_url(&config.chatgpt_base_url)
|
||||
{
|
||||
RouteAwareClientPool::new_without_request_logging(
|
||||
config
|
||||
.http_client_factory()
|
||||
.with_sensitive_header_protection(),
|
||||
ClientRouteClass::Api,
|
||||
)
|
||||
} else {
|
||||
RouteAwareClientPool::new_without_request_logging(
|
||||
config.http_client_factory(),
|
||||
ClientRouteClass::Api,
|
||||
)
|
||||
.with_legacy_custom_ca_fallback()
|
||||
},
|
||||
session_telemetry,
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
git_root_discovery,
|
||||
|
||||
@@ -99,6 +99,7 @@ use codex_protocol::protocol::TurnEnvironmentRequest;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_rollout::state_db::StateDbHandle;
|
||||
use codex_runtime_agent_instance::RuntimeAuthManager;
|
||||
use codex_skills_extension::HostSkillsService;
|
||||
use codex_thread_store::LoadThreadHistoryParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
@@ -599,6 +600,7 @@ impl ThreadManager {
|
||||
codex_home.to_path_buf(),
|
||||
restriction_product,
|
||||
Arc::clone(&auth_manager),
|
||||
Arc::clone(&runtime_auth),
|
||||
skills_service.clone(),
|
||||
));
|
||||
let mcp_manager = Arc::new(McpManager::new_with_extensions(
|
||||
@@ -809,10 +811,12 @@ impl ThreadManager {
|
||||
/*bundled_skills_enabled*/ true,
|
||||
restriction_product,
|
||||
));
|
||||
let runtime_auth = crate::runtime_auth::RuntimeAuthSlot::default();
|
||||
let plugins_manager = Arc::new(PluginsManager::new_with_options(
|
||||
codex_home.clone(),
|
||||
restriction_product,
|
||||
Arc::clone(&auth_manager),
|
||||
Arc::clone(&runtime_auth),
|
||||
skills_service.clone(),
|
||||
));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
@@ -829,7 +833,7 @@ impl ThreadManager {
|
||||
let agent_graph_store = local_agent_graph_store_from_state_db(state_db.as_ref());
|
||||
Self {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
runtime_auth: Default::default(),
|
||||
runtime_auth,
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
shared_thread_instructions: Default::default(),
|
||||
thread_created_tx,
|
||||
@@ -882,6 +886,30 @@ impl ThreadManager {
|
||||
self.state.auth_manager.clone()
|
||||
}
|
||||
|
||||
/// Share the harness runtime with an API client using this effective ChatGPT configuration.
|
||||
pub fn runtime_auth_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
) -> std::io::Result<Option<(Arc<RuntimeAuthManager>, codex_features::RuntimeAuthMode)>> {
|
||||
if config
|
||||
.features
|
||||
.enabled(codex_features::Feature::RuntimeAgentInstances)
|
||||
{
|
||||
codex_model_provider::validate_runtime_destination(Some(&config.chatgpt_base_url))
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
if !codex_runtime_agent_instance::is_openai_api_url(&config.chatgpt_base_url) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(crate::runtime_auth::runtime_for_config(
|
||||
&self.state.runtime_auth,
|
||||
config,
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
&self.state.installation_id,
|
||||
)
|
||||
.map(|runtime| (runtime, config.runtime_auth_mode)))
|
||||
}
|
||||
|
||||
pub fn skills_service(&self) -> Arc<HostSkillsService> {
|
||||
self.state.skills_service.clone()
|
||||
}
|
||||
@@ -2577,20 +2605,37 @@ impl ThreadManagerState {
|
||||
} else {
|
||||
codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile
|
||||
};
|
||||
// Resume may supply a different credential owner. Keep its runtime separate from the
|
||||
// harness default, and let sessions using that parent's credentials inherit its owner.
|
||||
let runtime_auth = if let Some(runtime_auth) = inherited_runtime_auth {
|
||||
runtime_auth
|
||||
} else if Arc::ptr_eq(&auth_manager, &self.auth_manager) {
|
||||
Arc::clone(&self.runtime_auth)
|
||||
// A resumed actor and its descendants share an owner, not the harness default's credentials.
|
||||
let (runtime_auth, plugins_manager) = if Arc::ptr_eq(&auth_manager, &self.auth_manager) {
|
||||
(
|
||||
inherited_runtime_auth.unwrap_or_else(|| Arc::clone(&self.runtime_auth)),
|
||||
Arc::clone(&self.plugins_manager),
|
||||
)
|
||||
} else {
|
||||
let threads = self.threads.read().await;
|
||||
parent_thread_id
|
||||
if let Some(parent) = parent_thread_id
|
||||
.or(forked_from_thread_id)
|
||||
.and_then(|id| threads.get(&id))
|
||||
.filter(|parent| Arc::ptr_eq(&parent.session.services.auth_manager, &auth_manager))
|
||||
.map(|parent| Arc::clone(&parent.session.services.runtime_auth))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
(
|
||||
inherited_runtime_auth
|
||||
.unwrap_or_else(|| Arc::clone(&parent.session.services.runtime_auth)),
|
||||
Arc::clone(&parent.session.services.plugins_manager),
|
||||
)
|
||||
} else {
|
||||
let runtime_auth = inherited_runtime_auth.unwrap_or_default();
|
||||
let plugins = Arc::new(
|
||||
self.plugins_manager
|
||||
.for_auth_context(Arc::clone(&auth_manager), Arc::clone(&runtime_auth)),
|
||||
);
|
||||
(runtime_auth, plugins)
|
||||
}
|
||||
};
|
||||
let mcp_manager = if Arc::ptr_eq(&plugins_manager, &self.plugins_manager) {
|
||||
mcp_manager
|
||||
} else {
|
||||
Arc::new(mcp_manager.for_plugins_manager(Arc::clone(&plugins_manager)))
|
||||
};
|
||||
let runtime = crate::runtime_auth::runtime_for_config(
|
||||
&runtime_auth,
|
||||
@@ -2621,7 +2666,7 @@ impl ThreadManagerState {
|
||||
git_root_discovery: Arc::clone(&self.git_root_discovery),
|
||||
environment_manager: Arc::clone(&self.environment_manager),
|
||||
skills_service: Arc::clone(&self.skills_service),
|
||||
plugins_manager: Arc::clone(&self.plugins_manager),
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
code_mode_session_provider: Arc::clone(&self.code_mode_session_provider),
|
||||
extensions,
|
||||
|
||||
@@ -191,7 +191,10 @@ impl RequestPluginInstallHandler {
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let plugins_config = turn.config.plugins_config_input();
|
||||
match codex_core_plugins::hydrate_selected_recommended_plugin_install_metadata(
|
||||
&plugins_config,
|
||||
&session
|
||||
.services
|
||||
.plugins_manager
|
||||
.remote_plugin_service_config(&plugins_config),
|
||||
auth.as_ref(),
|
||||
tool,
|
||||
)
|
||||
|
||||
@@ -31,20 +31,24 @@ codex-hooks = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-thread-store = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "test-util", "time"] }
|
||||
tokio-rustls = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod exec_server;
|
||||
pub mod hooks;
|
||||
pub mod process;
|
||||
pub mod responses;
|
||||
pub mod runtime_auth;
|
||||
pub mod startup;
|
||||
pub mod streaming_sse;
|
||||
pub mod test_codex;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Route isolated first-party auth tests to loopback without relaxing destination checks.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::process::Command;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tokio_rustls::rustls;
|
||||
use wiremock::MockServer;
|
||||
|
||||
pub const PROBE_SERVER: &str = "CODEX_RUNTIME_AUTH_TEST_SERVER";
|
||||
|
||||
/// Run one test in an isolated subprocess with first-party HTTP routed to the mock server.
|
||||
/// An optional Realtime target receives chatgpt.com Realtime WebSocket upgrades.
|
||||
pub async fn run_harness_through_proxy(
|
||||
server: &MockServer,
|
||||
test_name: &str,
|
||||
realtime_target: Option<std::net::SocketAddr>,
|
||||
) -> Result<()> {
|
||||
// The production destination check stays intact. A test-only CA and CONNECT proxy route
|
||||
// the real host names to wiremock, only inside this isolated test subprocess.
|
||||
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
|
||||
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![
|
||||
"api.openai.com".into(),
|
||||
"auth.openai.com".into(),
|
||||
"chatgpt.com".into(),
|
||||
"us.chatgpt.com".into(),
|
||||
])?;
|
||||
let ca_dir = tempfile::tempdir()?;
|
||||
let ca_path = ca_dir.path().join("ca.pem");
|
||||
std::fs::write(&ca_path, cert.pem())?;
|
||||
let acceptor = TlsAcceptor::from(Arc::new(
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert.der().clone()], signing_key.into())?,
|
||||
));
|
||||
let proxy = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let proxy_url = format!("http://{}", proxy.local_addr()?);
|
||||
let target = *server.address();
|
||||
let proxy_task = tokio::spawn(async move {
|
||||
let mut connections = JoinSet::new();
|
||||
loop {
|
||||
let (mut stream, _) = proxy.accept().await?;
|
||||
let acceptor = acceptor.clone();
|
||||
connections.spawn(async move {
|
||||
let mut headers = Vec::new();
|
||||
while !headers.ends_with(b"\r\n\r\n") && headers.len() < 8192 {
|
||||
headers.push(stream.read_u8().await?);
|
||||
}
|
||||
let headers = std::str::from_utf8(&headers)?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
headers.lines().next(),
|
||||
Some(
|
||||
"CONNECT api.openai.com:443 HTTP/1.1"
|
||||
| "CONNECT auth.openai.com:443 HTTP/1.1"
|
||||
| "CONNECT chatgpt.com:443 HTTP/1.1"
|
||||
| "CONNECT us.chatgpt.com:443 HTTP/1.1"
|
||||
)
|
||||
),
|
||||
"unexpected proxy destination"
|
||||
);
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
let realtime_host = headers.starts_with("CONNECT chatgpt.com:443 ");
|
||||
let mut stream = acceptor.accept(stream).await?;
|
||||
let mut prefix = Vec::new();
|
||||
let mut destination = target;
|
||||
if let Some(realtime_target) = realtime_target.filter(|_| realtime_host) {
|
||||
// Background HTTP requests share this host with Realtime. Only upgrades
|
||||
// belong on the WebSocket fixture; ordinary HTTP still goes to wiremock.
|
||||
while !prefix.ends_with(b"\r\n\r\n") && prefix.len() < 65536 {
|
||||
prefix.push(stream.read_u8().await?);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
prefix.ends_with(b"\r\n\r\n"),
|
||||
"oversized test request headers"
|
||||
);
|
||||
let request_headers = std::str::from_utf8(&prefix)?;
|
||||
let realtime_path = request_headers.lines().next().is_some_and(|line| {
|
||||
line.starts_with("GET /v1/realtime") || line.starts_with("GET /v1/live")
|
||||
});
|
||||
if realtime_path
|
||||
&& request_headers.lines().any(|line| {
|
||||
line.split_once(':').is_some_and(|(name, value)| {
|
||||
name.eq_ignore_ascii_case("upgrade")
|
||||
&& value.trim().eq_ignore_ascii_case("websocket")
|
||||
})
|
||||
})
|
||||
{
|
||||
destination = realtime_target;
|
||||
}
|
||||
}
|
||||
let mut target = TcpStream::connect(destination).await?;
|
||||
target.write_all(&prefix).await?;
|
||||
tokio::io::copy_bidirectional(&mut stream, &mut target).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
let mut command = Command::new(std::env::current_exe()?);
|
||||
command
|
||||
.args(["--exact", test_name, "--nocapture"])
|
||||
.kill_on_drop(true);
|
||||
for &key in codex_network_proxy::PROXY_ENV_KEYS {
|
||||
command.env_remove(key);
|
||||
}
|
||||
command
|
||||
.env(PROBE_SERVER, server.uri())
|
||||
.env("HTTPS_PROXY", proxy_url)
|
||||
.env("NO_PROXY", codex_network_proxy::DEFAULT_NO_PROXY_VALUE)
|
||||
.env("CODEX_CA_CERTIFICATE", ca_path);
|
||||
let output = tokio::time::timeout(Duration::from_secs(90), command.output()).await;
|
||||
proxy_task.abort();
|
||||
let output = output??;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"harness subprocess failed\n{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -75,6 +75,9 @@ use wiremock::matchers::query_param;
|
||||
use super::rmcp_client::remote_aware_environment_id;
|
||||
use super::rmcp_client::remote_aware_stdio_server_bin;
|
||||
|
||||
#[path = "request_plugin_install_runtime_auth.rs"]
|
||||
mod runtime_auth_tests;
|
||||
|
||||
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
||||
const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install";
|
||||
const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Exercise recommended-plugin metadata after resuming with another account.
|
||||
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use codex_features::RuntimeAuthMode;
|
||||
use codex_history::InitialHistory;
|
||||
use codex_history::ResumedHistory;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use core_test_support::runtime_auth::PROBE_SERVER;
|
||||
use core_test_support::runtime_auth::run_harness_through_proxy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use test_case::test_case;
|
||||
|
||||
fn actor_token(actor: &str) -> String {
|
||||
let claims = json!({
|
||||
"exp": 4_102_444_800_u64,
|
||||
"session_id": format!("session-{actor}"),
|
||||
"iss": "https://auth.openai.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_user_id": format!("user-{actor}"),
|
||||
"chatgpt_account_id": format!("account-{actor}"),
|
||||
},
|
||||
});
|
||||
format!(
|
||||
"e30.{}.c2ln",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string())
|
||||
)
|
||||
}
|
||||
|
||||
#[test_case(RuntimeAuthMode::DualHeader; "dual_header")]
|
||||
#[test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resumed_plugin_install_uses_the_threads_account(mode: RuntimeAuthMode) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
const PLUGIN_ID: &str = "plugin_connector_github";
|
||||
if std::env::var(PROBE_SERVER).is_ok() {
|
||||
let server = start_mock_server().await;
|
||||
let requests = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![ev_completed("original")]),
|
||||
sse(vec![
|
||||
ev_function_call(
|
||||
"install-github",
|
||||
REQUEST_PLUGIN_INSTALL_TOOL_NAME,
|
||||
&json!({
|
||||
"plugin_id": "github@openai-curated-remote",
|
||||
"suggest_reason": "Use GitHub for this request",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ev_completed("install"),
|
||||
]),
|
||||
sse(vec![ev_completed("finished")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::from_external_chatgpt_tokens(
|
||||
&actor_token("a"),
|
||||
"account-a",
|
||||
Some("plus"),
|
||||
)?)
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::Disabled)
|
||||
.expect("test config should allow disabled permissions");
|
||||
configure_apps_without_search_tool(config, "https://chatgpt.com");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::RuntimeAgentInstances)
|
||||
.unwrap();
|
||||
config.runtime_auth_mode = mode;
|
||||
});
|
||||
let test = builder.build_with_auto_env(&server).await?;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "Original account".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
test.codex.ensure_rollout_materialized().await;
|
||||
test.codex.flush_rollout().await?;
|
||||
let rollout = test.codex.rollout_path().context("original rollout")?;
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
let _ = test
|
||||
.thread_manager
|
||||
.remove_thread(&test.session_configured.thread_id)
|
||||
.await;
|
||||
let history = test
|
||||
.thread_store
|
||||
.load_latest_model_context(codex_thread_store::LoadThreadHistoryParams {
|
||||
thread_id: test.session_configured.thread_id,
|
||||
include_archived: false,
|
||||
})
|
||||
.await?;
|
||||
let replacement = codex_core::test_support::auth_manager_from_auth_with_home(
|
||||
CodexAuth::from_external_chatgpt_tokens(&actor_token("b"), "account-b", Some("plus"))?,
|
||||
test.codex_home_path().to_path_buf(),
|
||||
);
|
||||
let resumed = test
|
||||
.thread_manager
|
||||
.resume_thread_with_history(
|
||||
test.config.clone(),
|
||||
InitialHistory::Resumed(ResumedHistory {
|
||||
history_revision: None,
|
||||
conversation_id: history.thread_id,
|
||||
history: Arc::new(history.items),
|
||||
rollout_path: Some(rollout),
|
||||
}),
|
||||
replacement,
|
||||
/*parent_trace*/ None,
|
||||
ClientMcpExtensions::default(),
|
||||
)
|
||||
.await?;
|
||||
resumed
|
||||
.thread
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "Use GitHub".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
let elicitation = wait_for_event_match(&resumed.thread, |event| match event {
|
||||
EventMsg::ElicitationRequest(request) => Some(Ok(request.clone())),
|
||||
EventMsg::TurnComplete(_) => Some(Err(anyhow::anyhow!(
|
||||
"the resumed account must reach the plugin installation prompt"
|
||||
))),
|
||||
_ => None,
|
||||
})
|
||||
.await?;
|
||||
let ElicitationRequest::Form {
|
||||
meta: Some(meta), ..
|
||||
} = &elicitation.request
|
||||
else {
|
||||
anyhow::bail!("expected plugin installation metadata");
|
||||
};
|
||||
assert_eq!(meta["remote_plugin_id"], PLUGIN_ID);
|
||||
resumed
|
||||
.thread
|
||||
.submit(Op::ResolveElicitation {
|
||||
server_name: elicitation.server_name,
|
||||
request_id: elicitation.id,
|
||||
decision: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&resumed.thread, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(requests.requests().len(), 3);
|
||||
resumed.thread.shutdown_and_wait().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _apps = AppsTestServer::mount(&server).await?;
|
||||
mount_recommendations(
|
||||
&server,
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"enabled": true,
|
||||
"plugins": [{"id": PLUGIN_ID, "name": "github", "display_name": "GitHub"}],
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/agent/register"))
|
||||
.respond_with(|request: &wiremock::Request| {
|
||||
let body: Value = request.body_json().unwrap();
|
||||
let actor = if body["actor_auth"]["credential"] == actor_token("a") {
|
||||
"a"
|
||||
} else {
|
||||
"b"
|
||||
};
|
||||
assert_eq!(body["actor_auth"]["credential"], actor_token(actor));
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"runtime_agent_instance_id": format!("runtime-{actor}"),
|
||||
"agent_host_id": "host-a",
|
||||
"actor": {
|
||||
"kind": "chatgpt",
|
||||
"user_id": format!("user-{actor}"),
|
||||
"account_id": format!("account-{actor}"),
|
||||
},
|
||||
}))
|
||||
})
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/ps/plugins/{PLUGIN_ID}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": PLUGIN_ID, "name": "github", "scope": "GLOBAL", "status": "ENABLED",
|
||||
"installation_policy": "AVAILABLE", "authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
"display_name": "GitHub", "description": "Work with GitHub repositories.",
|
||||
"app_ids": [], "interface": {},
|
||||
},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let case = match mode {
|
||||
RuntimeAuthMode::DualHeader => "dual_header",
|
||||
RuntimeAuthMode::AssertionOnly => "assertion_only",
|
||||
};
|
||||
run_harness_through_proxy(
|
||||
&server,
|
||||
&format!("suite::request_plugin_install::runtime_auth_tests::resumed_plugin_install_uses_the_threads_account::{case}"),
|
||||
/*realtime_target*/ None,
|
||||
).await?;
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.context("recorded requests")?;
|
||||
let metadata = requests
|
||||
.iter()
|
||||
.find(|request| request.url.path() == format!("/ps/plugins/{PLUGIN_ID}"))
|
||||
.context("plugin metadata request")?;
|
||||
let proof = match mode {
|
||||
RuntimeAuthMode::DualHeader => {
|
||||
assert_eq!(
|
||||
metadata.headers["authorization"],
|
||||
format!("Bearer {}", actor_token("b"))
|
||||
);
|
||||
metadata.headers["x-openai-agent-assertion"].to_str()?
|
||||
}
|
||||
RuntimeAuthMode::AssertionOnly => {
|
||||
assert!(!metadata.headers.contains_key("x-openai-agent-assertion"));
|
||||
metadata.headers["authorization"]
|
||||
.to_str()?
|
||||
.strip_prefix("AgentAssertion ")
|
||||
.context("assertion auth")?
|
||||
}
|
||||
};
|
||||
let claims: Value = serde_json::from_slice(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(proof.split('.').nth(2).context("assertion payload")?)?,
|
||||
)?;
|
||||
assert_eq!(claims["runtime_agent_instance"], "runtime-b");
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,6 +87,27 @@ async fn harness_shares_runtime_and_honors_thread_auth_config() -> Result<()> {
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
Mock::given(path("/backend-api/files"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"file_id": "file-a", "upload_url": format!("{}/blob", server.uri()),
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/backend-api/files/file-a/uploaded"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"status": "success", "download_url": format!("{}/blob", server.uri()),
|
||||
"file_name": "empty.txt", "file_size_bytes": 0,
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/blob"))
|
||||
.and(method("PUT"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let responses = responses::mount_response_sequence(
|
||||
&server,
|
||||
(0..14)
|
||||
@@ -131,11 +152,13 @@ async fn harness_shares_runtime_and_honors_thread_auth_config() -> Result<()> {
|
||||
.await;
|
||||
|
||||
// The production destination check stays intact. A test-only CA and CONNECT proxy route
|
||||
// the two real host names to wiremock, only inside this isolated test subprocess.
|
||||
// the real host names to wiremock, only inside this isolated test subprocess.
|
||||
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
|
||||
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![
|
||||
"api.openai.com".into(),
|
||||
"auth.openai.com".into(),
|
||||
"chatgpt.com".into(),
|
||||
"us.chatgpt.com".into(),
|
||||
])?;
|
||||
let ca_dir = tempfile::tempdir()?;
|
||||
let ca_path = ca_dir.path().join("ca.pem");
|
||||
@@ -165,6 +188,8 @@ async fn harness_shares_runtime_and_honors_thread_auth_config() -> Result<()> {
|
||||
Some(
|
||||
"CONNECT api.openai.com:443 HTTP/1.1"
|
||||
| "CONNECT auth.openai.com:443 HTTP/1.1"
|
||||
| "CONNECT chatgpt.com:443 HTTP/1.1"
|
||||
| "CONNECT us.chatgpt.com:443 HTTP/1.1"
|
||||
)
|
||||
),
|
||||
"unexpected proxy destination"
|
||||
@@ -337,6 +362,29 @@ async fn harness_shares_runtime_and_honors_thread_auth_config() -> Result<()> {
|
||||
assert_eq!(payload["runtime_agent_instance"], "runtime-shared");
|
||||
assert!(proof_ids.insert(payload["id"].as_str().unwrap().to_owned()));
|
||||
}
|
||||
let files: Vec<_> = requests
|
||||
.iter()
|
||||
.filter(|r| r.url.path().starts_with("/backend-api/files"))
|
||||
.collect();
|
||||
assert_eq!(files.len(), 4);
|
||||
for (index, request) in files.iter().enumerate() {
|
||||
let authorization = request.headers["authorization"].to_str()?;
|
||||
let proof = if index < 2 {
|
||||
assert_eq!(authorization, "Bearer key-a");
|
||||
request.headers["x-openai-agent-assertion"].to_str()?
|
||||
} else {
|
||||
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
|
||||
authorization
|
||||
.strip_prefix("AgentAssertion ")
|
||||
.context("assertion-only file auth")?
|
||||
};
|
||||
let payload: Value = serde_json::from_slice(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(proof.split('.').nth(2).unwrap())?,
|
||||
)?;
|
||||
assert_eq!(payload["runtime_agent_instance"], "runtime-shared");
|
||||
assert!(proof_ids.insert(payload["id"].as_str().unwrap().to_owned()));
|
||||
}
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -420,6 +468,7 @@ async fn exercise_harness(server_uri: String) -> Result<()> {
|
||||
.await?;
|
||||
turn(&parent.thread, "parent-dual").await?;
|
||||
exercise_provider_destinations(&parent.thread, &enabled).await?;
|
||||
upload_with_harness(&test.thread_manager, &enabled).await?;
|
||||
let mut child_config = enabled.clone();
|
||||
child_config.runtime_auth_mode = RuntimeAuthMode::AssertionOnly;
|
||||
parent.thread.ensure_rollout_materialized().await;
|
||||
@@ -442,13 +491,14 @@ async fn exercise_harness(server_uri: String) -> Result<()> {
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
..StartThreadOptions::new(child_config)
|
||||
..StartThreadOptions::new(child_config.clone())
|
||||
},
|
||||
prepared,
|
||||
)
|
||||
.await?;
|
||||
turn(&child.thread, "child-assertion").await?;
|
||||
exercise_provider_destinations(&child.thread, &enabled).await?;
|
||||
upload_with_harness(&test.thread_manager, &child_config).await?;
|
||||
parent
|
||||
.thread
|
||||
.submit(Op::Review {
|
||||
@@ -648,3 +698,33 @@ async fn turn(thread: &Arc<CodexThread>, label: &str) -> Result<()> {
|
||||
wait_for_event(thread, |e| matches!(e, EventMsg::TurnComplete(_))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_with_harness(
|
||||
manager: &codex_core::ThreadManager,
|
||||
config: &codex_core::config::Config,
|
||||
) -> Result<()> {
|
||||
let (runtime, mode) = manager
|
||||
.runtime_auth_for_config(config)?
|
||||
.context("enabled harness runtime")?;
|
||||
let selected = runtime.auth().await?;
|
||||
let clients =
|
||||
codex_http_client::RouteAwareClientPool::new_without_redirects_or_request_logging(
|
||||
config.http_client_factory(),
|
||||
codex_http_client::ClientRouteClass::Api,
|
||||
)
|
||||
.with_account_binding(selected.http_client_factory().network_policy())
|
||||
.context("upload auth and transport share the application policy")?;
|
||||
let auth = selected.into_auth_provider(mode);
|
||||
let uploaded = codex_api::upload_openai_file(
|
||||
&config.chatgpt_base_url,
|
||||
auth.as_ref(),
|
||||
&clients,
|
||||
"empty.txt".into(),
|
||||
/*file_size_bytes*/ 0,
|
||||
|| async { Ok(futures::stream::empty()) },
|
||||
/*hosted_upload*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(uploaded.uri, "sediment://file-a");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user