Preserve enterprise MCP authorization across runtime refreshes (#55696)

## Why

Configuration reloads must cancel stale enterprise MCP startups while preserving access for running threads that still pass validation. Replacing a runtime during `thread/revert` must also leave the replacement able to use enterprise tools.

## What changed

- Separate pending startup cancellation from published thread lifetimes, with independent startup state for forks and replacement runtimes.
- Retain eligible enterprise MCP connections across refreshes and explicit reconnects while applying updated tool restrictions and approvals. Do not create replacement enterprise connections after initial startup.
- Revoke enterprise authorization when a thread is removed, drain requests when access is disabled, and revoke a connection's authority when token renewal requires authentication.

## Testing

Extend regression coverage to verify enterprise tool calls survive `thread/revert`, inherited fork configurations are cancelled after a reload, and enterprise calls fail after logout while ordinary MCP calls remain available.

GitOrigin-RevId: 2d634be3011854b57f73c741bd1ec6b5aed94ee5
This commit is contained in:
jif
2026-10-20 23:03:56 +00:00
committed by copyberry
parent 6fb1c3903d
commit feadd9441e
20 changed files with 468 additions and 85 deletions
+13 -3
View File
@@ -92,8 +92,8 @@ impl Config {
let is_enterprise = |server: &McpServerConfig| {
matches!(server.auth, McpServerAuth::EmaAuth) || server.ema_registration().is_some()
};
// Refresh ordinary servers from the complete host-owned set. Keep the
// initially admitted EMA configuration until a fresh session is constructed.
// Refresh ordinary servers from the complete host-owned set. Keep EMA
// admission pinned, while applying current tool restrictions and approvals.
let mut servers: HashMap<_, _> = incoming
.mcp_servers
.get()
@@ -109,7 +109,17 @@ impl Config {
.get()
.iter()
.filter(|(_, server)| is_enterprise(server))
.map(|(name, server)| (name.clone(), server.clone())),
.map(|(name, server)| {
let mut server = server.clone();
if let Some(incoming) = incoming.mcp_servers.get().get(name) {
server.enabled_tools.clone_from(&incoming.enabled_tools);
server.disabled_tools.clone_from(&incoming.disabled_tools);
server.omit_tools_from.clone_from(&incoming.omit_tools_from);
server.default_tools_approval_mode = incoming.default_tools_approval_mode;
server.tools.clone_from(&incoming.tools);
}
(name.clone(), server)
}),
);
let mut config = self.clone();
if !matches!(scope, RuntimeConfigRefresh::UserFiles) {
+32 -12
View File
@@ -14,18 +14,20 @@ use tracing::warn;
impl Session {
pub(crate) async fn disable_mcp_enterprise_auth(&self) {
let mut state = self.state.lock().await;
let mut config = state
.session_configuration
.original_config_do_not_use
.as_ref()
.clone();
config.config_layer_stack.mcp_ema_cancellation().cancel();
config.disable_mcp_enterprise_auth();
state.session_configuration.original_config_do_not_use = Arc::new(config);
self.services.mcp_runtime.invalidate_resource_caches();
self.mark_mcp_runtime_dirty();
drop(state);
{
let mut state = self.state.lock().await;
let mut config = state
.session_configuration
.original_config_do_not_use
.as_ref()
.clone();
config.config_layer_stack.mcp_ema_cancellation().cancel();
config.disable_mcp_enterprise_auth();
state.session_configuration.original_config_do_not_use = Arc::new(config);
self.services.mcp_runtime.invalidate_resource_caches();
self.mark_mcp_runtime_dirty();
}
self.services.mcp_runtime.quiesce_ema().await;
self.schedule_mcp_prewarm();
}
@@ -164,6 +166,17 @@ impl Session {
if config.mcp_enterprise_managed_auth.is_none() {
config.config_layer_stack.mcp_ema_cancellation().cancel();
}
if !matches!(scope, RuntimeConfigRefresh::UserFiles) {
let cancellation = config
.config_layer_stack
.mcp_ema_cancellation()
.refreshed(next_config.config_layer_stack.mcp_ema_cancellation());
let config = Arc::make_mut(&mut config);
config.config_layer_stack = config
.config_layer_stack
.clone()
.with_mcp_ema_cancellation(cancellation);
}
state.session_configuration.original_config_do_not_use = Arc::clone(&config);
drop(policy_guard);
if matches!(scope, RuntimeConfigRefresh::Mcp) {
@@ -174,6 +187,13 @@ impl Session {
.then(|| self.build_effective_session_config(&state.session_configuration));
(previous_config, new_config)
};
if config
.config_layer_stack
.mcp_ema_cancellation()
.is_cancelled()
{
self.services.mcp_runtime.quiesce_ema().await;
}
self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref());
self.schedule_mcp_prewarm();
self.refresh_hooks(config).await;
+36 -5
View File
@@ -1408,7 +1408,7 @@ impl ThreadManager {
/// as `Arc<CodexThread>`, it is possible that other references to it exist elsewhere.
/// Returns the thread if the thread was found and removed.
pub async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
self.state.threads.write().await.remove(thread_id)
self.state.remove_thread(thread_id).await
}
/// Removes a runtime for a client request, leaving internal workers with their owner.
@@ -1426,7 +1426,9 @@ impl ThreadManager {
"live internal threads can only be removed by their owner".to_owned(),
));
}
Ok(threads.remove(thread_id))
Ok(threads.remove(thread_id).inspect(|thread| {
thread.session.services.mcp_runtime.revoke_ema();
}))
}
/// Removes a thread only if `thread_id` still maps to `expected`.
@@ -1443,7 +1445,9 @@ impl ThreadManager {
.get(thread_id)
.is_some_and(|thread| Arc::ptr_eq(thread, expected))
{
threads.remove(thread_id)
threads.remove(thread_id).inspect(|thread| {
thread.session.services.mcp_runtime.revoke_ema();
})
} else {
None
}
@@ -1485,7 +1489,9 @@ impl ThreadManager {
let mut tracked_threads = self.state.threads.write().await;
for thread_id in &report.completed {
tracked_threads.remove(thread_id);
if let Some(thread) = tracked_threads.remove(thread_id) {
thread.session.services.mcp_runtime.revoke_ema();
}
}
report
@@ -1850,7 +1856,13 @@ impl ThreadManagerState {
/// Remove a thread from the manager by ID, returning it when present.
pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
self.threads.write().await.remove(thread_id)
self.threads
.write()
.await
.remove(thread_id)
.inspect(|thread| {
thread.session.services.mcp_runtime.revoke_ema();
})
}
pub(crate) async fn effective_multi_agent_version_for_spawn(
@@ -2094,6 +2106,17 @@ impl ThreadManagerState {
/// Spawn a new thread with optional history and register it with the manager.
async fn spawn_thread(&self, mut request: ThreadSpawnRequest) -> CodexResult<NewThread> {
let cancellation = request
.options
.config
.config_layer_stack
.mcp_ema_cancellation()
.for_startup();
request.options.config.config_layer_stack = request
.options
.config
.config_layer_stack
.with_mcp_ema_cancellation(cancellation);
let runtime = request.agent_control.runtime().clone();
let membership = runtime.admit_start()?;
let owns_startup = request.startup.is_none();
@@ -2241,6 +2264,7 @@ impl ThreadManagerState {
});
}
threads.remove(&resumed.conversation_id);
thread.session.services.mcp_runtime.revoke_ema();
}
}
let agent_control = match (&self.agent_control_factory, agent_control) {
@@ -2511,6 +2535,12 @@ impl ThreadManagerState {
}
};
let ema_cancellation = session
.get_config()
.await
.config_layer_stack
.mcp_ema_cancellation()
.clone();
{
let mut threads = self.threads.write().await;
if let std::collections::hash_map::Entry::Vacant(e) = threads.entry(thread_id) {
@@ -2520,6 +2550,7 @@ impl ThreadManagerState {
.agent_control
.propagate_config_update(update);
}
ema_cancellation.publish();
let thread = Arc::new(CodexThread::new(
session,
io,