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
+5 -2
View File
@@ -343,10 +343,13 @@ pub enum CloudConfigBundleBindingStatus {
}
impl CloudConfigBundleBinding {
/// Completes when this captured policy can no longer authorize EMA requests.
/// Wait until this delivered policy no longer authorizes its EMA owner.
pub async fn invalidated(&self) {
let mut changes = self.policy.0.subscribe();
while self.read().status == CloudConfigBundleBindingStatus::Current {
loop {
if self.read().status != CloudConfigBundleBindingStatus::Current {
return;
}
if changes.changed().await.is_err() {
return;
}
+2
View File
@@ -23,6 +23,8 @@ pub mod loader;
mod marketplace_edit;
mod mcp_edit;
mod mcp_ema;
mod mcp_ema_cancellation;
pub use mcp_ema_cancellation::McpEmaCancellation;
mod mcp_requirements;
mod mcp_types;
mod merge;
@@ -0,0 +1,84 @@
//! Carries EMA startup cancellation across configuration loading and thread publication.
//! Once published, a thread's existing config refresh gate owns selective revocation.
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug, Default)]
pub struct McpEmaCancellation(Arc<State>);
#[derive(Debug, Default)]
struct State {
startup: CancellationToken,
owner: CancellationToken,
lifetime: CancellationToken,
published: AtomicBool,
}
impl McpEmaCancellation {
/// Capture before loading configuration so reload can fence a delayed startup.
pub fn pending(startup: CancellationToken, owner: CancellationToken) -> Self {
let lifetime = owner.child_token();
Self(Arc::new(State {
startup,
owner,
lifetime,
published: AtomicBool::new(false),
}))
}
/// A fork gets its own publication state, even when it inherits loaded config.
pub fn for_startup(&self) -> Self {
let pending = Self::pending(self.0.startup.clone(), self.0.owner.clone());
if self.is_cancelled() {
pending.cancel();
}
pending
}
/// A validated session keeps its authority and records the current startup fence
/// for later forks. The session publication gate must protect this replacement.
pub fn refreshed(&self, incoming: &Self) -> Self {
Self(Arc::new(State {
startup: incoming.0.startup.clone(),
owner: self.0.owner.clone(),
lifetime: self.0.lifetime.clone(),
published: AtomicBool::new(true),
}))
}
/// Call while holding the thread registry's publication lock. A reload either
/// cancels this pending snapshot or discovers the published thread for validation.
pub fn publish(&self) {
if !self.0.published.swap(true, Ordering::AcqRel) && self.0.startup.is_cancelled() {
self.cancel();
}
}
pub fn cancel(&self) {
self.0.lifetime.cancel();
}
pub fn is_cancelled(&self) -> bool {
self.0.lifetime.is_cancelled()
|| (!self.0.published.load(Ordering::Acquire) && self.0.startup.is_cancelled())
}
pub fn token(&self) -> CancellationToken {
self.0.lifetime.clone()
}
pub async fn cancelled(&self) {
tokio::select! {
biased;
() = self.0.lifetime.cancelled() => return,
() = self.0.startup.cancelled() => {}
}
if self.0.published.load(Ordering::Acquire) {
self.0.lifetime.cancelled().await;
}
}
}
+4 -7
View File
@@ -248,7 +248,7 @@ pub struct ConfigLayerStack {
/// Runtime-only EMA authority; it does not change the configuration's contents.
cloud_config_binding: Option<crate::CloudConfigBundleBinding>,
/// Runtime-only cancellation inherited by config snapshots and pending EMA startups.
mcp_ema_cancellation: tokio_util::sync::CancellationToken,
mcp_ema_cancellation: crate::McpEmaCancellation,
/// Cached TOML projection derived only from `requirements_toml`.
/// Construction validates provider definitions and reports serialization errors,
/// so `effective_config()` can replace complete entries without a fallible conversion.
@@ -314,7 +314,7 @@ impl ConfigLayerStack {
)?);
Ok(Self {
cloud_config_binding: None,
mcp_ema_cancellation: tokio_util::sync::CancellationToken::new(),
mcp_ema_cancellation: crate::McpEmaCancellation::default(),
model_provider_requirements,
layers,
requirements,
@@ -349,15 +349,12 @@ impl ConfigLayerStack {
self.cloud_config_binding.as_ref()
}
pub fn with_mcp_ema_cancellation(
mut self,
cancellation: tokio_util::sync::CancellationToken,
) -> Self {
pub fn with_mcp_ema_cancellation(mut self, cancellation: crate::McpEmaCancellation) -> Self {
self.mcp_ema_cancellation = cancellation;
self
}
pub fn mcp_ema_cancellation(&self) -> &tokio_util::sync::CancellationToken {
pub fn mcp_ema_cancellation(&self) -> &crate::McpEmaCancellation {
&self.mcp_ema_cancellation
}