Skip to main content

cookie_agent_engine/
plugin.rs

1use std::{
2    collections::{HashMap, HashSet, VecDeque},
3    future::Future,
4    pin::Pin,
5    process::Stdio,
6    sync::{
7        Arc, Mutex,
8        atomic::{AtomicU64, Ordering},
9    },
10    time::Duration,
11};
12
13use async_trait::async_trait;
14use cookie_agent_config::PluginConfig;
15use cookie_agent_protocol::{
16    ApprovalBoundary, ApprovalCapability, ApprovalResourceSource, EXTENSION_PROTOCOL_VERSION,
17    ErrorResponse, ExtensionBusEventParams, ExtensionEmitParams, ExtensionEmitResultParams,
18    ExtensionEmitStatus, ExtensionEventParams, ExtensionInitializeResult,
19    ExtensionInterceptionHook, ExtensionPingParams, ExtensionPingResult,
20    ExtensionPluginCapabilities, ExtensionToolCallParams, ExtensionToolCallResult,
21    ExtensionToolDeclaration, JsonRpcError, JsonRpcId, JsonRpcVersion, Notification,
22    PLUGIN_BUS_EVENT_METHOD, PLUGIN_EMIT_METHOD, PLUGIN_EMIT_RESULT_METHOD, PLUGIN_EVENT_METHOD,
23    PLUGIN_PING_METHOD, PLUGIN_TOOLS_CALL_METHOD, PermissionAction, PreparedApprovalResource,
24    PreparedBindingLifetime, PreparedCapabilityOperation, PreparedOperationIdentity,
25    PreparedResourceDigest, PreparedResourceIdentity, Request, Response, SafeDisplayText,
26    Sha256Digest, SuccessResponse, extension_initialize_request, extension_shutdown_notification,
27};
28#[cfg(test)]
29use cookie_agent_protocol::{
30    ExtensionAgentBeforeStartParams, ExtensionAgentBeforeStartResult,
31    ExtensionSessionBeforeCompactParams, ExtensionSessionBeforeCompactResult,
32    ExtensionToolAfterResultParams, ExtensionToolAfterResultResult, ExtensionToolBeforeCallParams,
33    ExtensionToolBeforeCallResult, PLUGIN_INTERCEPT_AGENT_BEFORE_START_METHOD,
34    PLUGIN_INTERCEPT_SESSION_BEFORE_COMPACT_METHOD, PLUGIN_INTERCEPT_TOOL_AFTER_RESULT_METHOD,
35    PLUGIN_INTERCEPT_TOOL_BEFORE_CALL_METHOD,
36};
37use cookie_agent_protocol::{
38    ExtensionInitializeParams, ExtensionProducerDiscardParams, ExtensionProducerDiscardResult,
39    ExtensionProducerRegisterParams, ExtensionProducerRegisterResult, ExtensionProducerSendParams,
40    ExtensionProducerSendResult, ExtensionProducerUnregisterParams,
41    ExtensionProducerUnregisterResult, ExtensionRecoveryCompleteParams,
42    ExtensionRecoveryCompleteResult, ExtensionRecoveryOutcome, PLUGIN_PRODUCER_DISCARD_METHOD,
43    PLUGIN_PRODUCER_REGISTER_METHOD, PLUGIN_PRODUCER_SEND_METHOD,
44    PLUGIN_PRODUCER_UNREGISTER_METHOD, PLUGIN_RECOVERY_COMPLETE_METHOD, PluginRecoveryState,
45    PluginRecoveryStatus, extension_recovery_start_notification,
46};
47use futures_util::StreamExt as _;
48use indexmap::IndexMap;
49use oven_sdk::JsonSchema;
50#[cfg(windows)]
51use process_wrap::tokio::JobObject;
52#[cfg(unix)]
53use process_wrap::tokio::ProcessGroup;
54use process_wrap::tokio::{ChildWrapper, CommandWrap, KillOnDrop};
55use serde_json::Value;
56use tokio::{
57    io::AsyncWriteExt as _,
58    sync::{Notify, mpsc, oneshot, watch},
59    task::{JoinHandle, JoinSet},
60    time::Instant,
61};
62use tokio_util::codec::{FramedRead, LinesCodec};
63
64use crate::tool_api::{
65    PreparedExecutor, PreparedTool, SessionToolContext, ToolCall, ToolError, ToolExecutionContext,
66    ToolPreparationContext, ToolProvider, ToolSpec,
67};
68
69const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
70pub(crate) const MAX_PLUGIN_EVENT_PAYLOAD_BYTES: usize = 256 * 1024;
71pub(crate) const MAX_PLUGIN_EVENT_NAME_CHARS: usize = 128;
72pub(crate) const MAX_PLUGIN_EVENT_TOTAL_BYTES: usize = 272 * 1024;
73const PLUGIN_OUTBOUND_CAPACITY: usize = 1024;
74const PLUGIN_PRODUCER_CONCURRENCY: usize = 32;
75const PLUGIN_NOTIFICATION_CAPACITY: usize = 1024;
76const PLUGIN_CONTEXT_CAPACITY: usize = 1024;
77const PLUGIN_SPENT_CONTEXT_CAPACITY: usize = 256;
78const PLUGIN_NOTIFICATION_CONTEXT_LIFETIME: Duration = Duration::from_secs(5);
79const PLUGIN_SPENT_CONTEXT_LIFETIME: Duration = Duration::from_secs(30);
80pub(crate) const PLUGIN_EVENTS_PER_SECOND: u32 = 40;
81pub(crate) const PLUGIN_BYTES_PER_MINUTE: usize = 4 * 1024 * 1024;
82
83#[derive(Debug)]
84struct PublishQuota {
85    second_started: Instant,
86    minute_started: Instant,
87    events_this_second: u32,
88    bytes_this_minute: usize,
89}
90
91#[derive(Clone, Debug)]
92pub(crate) struct PluginEmitRequest {
93    pub plugin: String,
94    pub session_id: cookie_agent_protocol::SessionId,
95    pub context: PluginEmitContext,
96    pub name: String,
97    pub payload: Value,
98    pub publish_bus: bool,
99    pub publish_session_events: bool,
100}
101
102#[derive(Clone, Debug)]
103pub(crate) enum PluginEmitContext {
104    Granted,
105    Rejected {
106        diagnostic_session_id: Option<cookie_agent_protocol::SessionId>,
107        reason: String,
108    },
109}
110
111#[derive(Clone, Debug)]
112pub(crate) struct PluginEmitOutcome {
113    pub bus: ExtensionEmitStatus,
114    pub durable: ExtensionEmitStatus,
115    pub reason: Option<String>,
116}
117
118#[derive(Clone, Debug)]
119pub(crate) struct PluginDeliveryDrop {
120    pub plugin: String,
121    pub class: PluginDeliveryClass,
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub(crate) enum PluginDeliveryClass {
126    Chunk,
127    Ordinary,
128    Terminal,
129}
130
131pub(crate) type PluginEmitHandler = Arc<
132    dyn Fn(PluginEmitRequest) -> Pin<Box<dyn Future<Output = PluginEmitOutcome> + Send>>
133        + Send
134        + Sync,
135>;
136
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub(crate) struct PluginConnectionAuthority {
139    pub plugin: String,
140    pub connection_epoch: u64,
141}
142
143#[derive(Clone, Debug)]
144pub(crate) enum PluginProducerRequest {
145    Register(ExtensionProducerRegisterParams),
146    Send(ExtensionProducerSendParams),
147    Unregister(ExtensionProducerUnregisterParams),
148    Discard(ExtensionProducerDiscardParams),
149    RecoveryComplete(ExtensionRecoveryCompleteParams),
150}
151
152#[derive(Clone, Debug)]
153pub(crate) enum PluginProducerResponse {
154    Register(ExtensionProducerRegisterResult),
155    Send(ExtensionProducerSendResult),
156    Unregister(ExtensionProducerUnregisterResult),
157    Discard(ExtensionProducerDiscardResult),
158    RecoveryComplete(ExtensionRecoveryCompleteResult),
159}
160
161pub(crate) type PluginProducerHandler = Arc<
162    dyn Fn(
163            PluginConnectionAuthority,
164            PluginProducerRequest,
165        )
166            -> Pin<Box<dyn Future<Output = Result<PluginProducerResponse, JsonRpcError>> + Send>>
167        + Send
168        + Sync,
169>;
170
171struct ProducerConnection {
172    epoch: Option<u64>,
173    live: bool,
174    recovery: Option<PluginRecoveryStatus>,
175    outcome: Option<ExtensionRecoveryOutcome>,
176}
177
178/// Invalidates authority even when the supervisor is aborted during shutdown.
179struct ProducerConnectionLease(Arc<PluginRuntime>, u64);
180
181impl Drop for ProducerConnectionLease {
182    fn drop(&mut self) {
183        let mut connection = self
184            .0
185            .producer_connection
186            .lock()
187            .unwrap_or_else(|p| p.into_inner());
188        if connection.epoch != Some(self.1) {
189            return;
190        }
191        connection.epoch = None;
192        connection.live = false;
193        if connection
194            .recovery
195            .is_some_and(|status| status != PluginRecoveryStatus::Disabled)
196        {
197            connection.recovery = Some(PluginRecoveryStatus::Failed);
198        }
199        drop(connection);
200        self.0
201            .producer_changes
202            .send_modify(|revision| *revision = revision.wrapping_add(1));
203    }
204}
205
206#[derive(Clone, Debug, PartialEq)]
207pub enum EngineEvent {
208    PluginEvent {
209        session_id: cookie_agent_protocol::SessionId,
210        plugin: String,
211        name: String,
212        payload: Value,
213    },
214}
215
216#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
217#[serde(rename_all = "snake_case")]
218pub enum PluginState {
219    Disabled,
220    Disconnected,
221    Connecting,
222    Connected,
223    Failed,
224}
225
226#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
227pub struct PluginStatus {
228    pub plugin: String,
229    pub state: PluginState,
230    pub reason: Option<String>,
231    pub tools: Vec<String>,
232    pub dropped_events: u64,
233}
234
235struct PluginRuntime {
236    name: String,
237    config: PluginConfig,
238    status: Mutex<PluginStatus>,
239    control: Mutex<Option<mpsc::Sender<Control>>>,
240    notifications: Mutex<Arc<PluginNotificationQueue>>,
241    declarations: Mutex<Vec<ExtensionToolDeclaration>>,
242    capabilities: Mutex<Option<ExtensionPluginCapabilities>>,
243    dropped_events: AtomicU64,
244    forced_failure: Mutex<Option<String>>,
245    forced_failure_notify: Notify,
246    ready: Arc<Notify>,
247    mcp: Arc<crate::McpRegistry>,
248    emit_handler: Arc<Mutex<Option<PluginEmitHandler>>>,
249    producer_handler: Arc<Mutex<Option<PluginProducerHandler>>>,
250    producer_connection: Mutex<ProducerConnection>,
251    producer_changes: watch::Sender<u64>,
252    contexts: Mutex<PluginContexts>,
253    publish_quotas: Mutex<HashMap<cookie_agent_protocol::SessionId, PublishQuota>>,
254}
255
256#[derive(Debug, Default)]
257struct PluginContexts {
258    active: VecDeque<PluginContextGrant>,
259    spent: VecDeque<PluginSpentContext>,
260}
261
262#[derive(Debug)]
263struct PluginContextGrant {
264    id: String,
265    session_id: cookie_agent_protocol::SessionId,
266    expires_at: Instant,
267}
268
269#[derive(Debug)]
270struct PluginSpentContext {
271    id: String,
272    session_id: cookie_agent_protocol::SessionId,
273    expires_at: Instant,
274}
275
276struct PluginContextLease {
277    runtime: Arc<PluginRuntime>,
278    context_id: String,
279    session_id: cookie_agent_protocol::SessionId,
280}
281
282impl Drop for PluginContextLease {
283    fn drop(&mut self) {
284        self.runtime
285            .revoke_context(&self.context_id, self.session_id);
286    }
287}
288
289enum Control {
290    ReplyFrame(Response),
291    Ping(oneshot::Sender<Result<(), String>>),
292    ToolCall {
293        params: ExtensionToolCallParams,
294        context_lifetime: Duration,
295        reply: oneshot::Sender<Result<ExtensionToolCallResult, String>>,
296    },
297    Notify {
298        notification: Notification,
299        session_id: cookie_agent_protocol::SessionId,
300        context_id: String,
301        context_lifetime: Duration,
302    },
303    Intercept {
304        method: &'static str,
305        params: Value,
306        session_id: Option<cookie_agent_protocol::SessionId>,
307        context_id: Option<String>,
308        context_lifetime: Option<Duration>,
309        reply: oneshot::Sender<Result<Value, String>>,
310    },
311    Shutdown,
312}
313
314struct QueuedPluginNotification {
315    control: Control,
316    class: PluginDeliveryClass,
317}
318
319struct PluginNotificationQueue {
320    capacity: usize,
321    queued: Mutex<VecDeque<QueuedPluginNotification>>,
322    notify: Notify,
323}
324
325impl PluginNotificationQueue {
326    fn new(capacity: usize) -> Self {
327        Self {
328            capacity: capacity.max(1),
329            queued: Mutex::new(VecDeque::new()),
330            notify: Notify::new(),
331        }
332    }
333
334    fn push(&self, control: Control, class: PluginDeliveryClass) -> Option<PluginDeliveryClass> {
335        let mut queued = self
336            .queued
337            .lock()
338            .unwrap_or_else(|poisoned| poisoned.into_inner());
339        let dropped = if queued.len() < self.capacity {
340            None
341        } else {
342            let index = match class {
343                PluginDeliveryClass::Chunk => queued
344                    .iter()
345                    .position(|item| item.class == PluginDeliveryClass::Chunk),
346                PluginDeliveryClass::Ordinary => queued
347                    .iter()
348                    .position(|item| item.class == PluginDeliveryClass::Chunk)
349                    .or_else(|| {
350                        queued
351                            .iter()
352                            .position(|item| item.class == PluginDeliveryClass::Ordinary)
353                    }),
354                PluginDeliveryClass::Terminal => queued
355                    .iter()
356                    .position(|item| item.class == PluginDeliveryClass::Chunk)
357                    .or_else(|| {
358                        queued
359                            .iter()
360                            .position(|item| item.class == PluginDeliveryClass::Ordinary)
361                    })
362                    .or_else(|| {
363                        queued
364                            .iter()
365                            .position(|item| item.class == PluginDeliveryClass::Terminal)
366                    }),
367            };
368            let Some(index) = index else {
369                return Some(class);
370            };
371            queued.remove(index).map(|item| item.class)
372        };
373        queued.push_back(QueuedPluginNotification { control, class });
374        drop(queued);
375        self.notify.notify_one();
376        dropped
377    }
378
379    #[cfg(test)]
380    fn pop(&self) -> Option<Control> {
381        self.queued
382            .lock()
383            .unwrap_or_else(|poisoned| poisoned.into_inner())
384            .pop_front()
385            .map(|item| item.control)
386    }
387
388    async fn notified(&self) {
389        self.notify.notified().await;
390    }
391
392    fn drain(&self) -> VecDeque<Control> {
393        std::mem::take(
394            &mut *self
395                .queued
396                .lock()
397                .unwrap_or_else(|poisoned| poisoned.into_inner()),
398        )
399        .into_iter()
400        .map(|item| item.control)
401        .collect()
402    }
403
404    fn clear(&self) {
405        self.queued
406            .lock()
407            .unwrap_or_else(|poisoned| poisoned.into_inner())
408            .clear();
409    }
410}
411
412enum ReaderEvent {
413    Message(String),
414    Failed(String),
415    Eof,
416}
417
418enum PendingRequest {
419    Initialize {
420        deadline: Instant,
421    },
422    Ping {
423        deadline: Instant,
424        reply: oneshot::Sender<Result<(), String>>,
425    },
426    ToolCall {
427        deadline: Instant,
428        reply: oneshot::Sender<Result<ExtensionToolCallResult, String>>,
429    },
430    Intercept {
431        deadline: Instant,
432        reply: oneshot::Sender<Result<Value, String>>,
433    },
434}
435
436impl PendingRequest {
437    fn deadline(&self) -> Instant {
438        match self {
439            Self::Initialize { deadline }
440            | Self::Ping { deadline, .. }
441            | Self::ToolCall { deadline, .. }
442            | Self::Intercept { deadline, .. } => *deadline,
443        }
444    }
445}
446
447struct PluginRegistryInner {
448    plugins: IndexMap<String, Arc<PluginRuntime>>,
449    tasks: Mutex<Vec<JoinHandle<()>>>,
450    ready: Arc<Notify>,
451    emit_handler: Arc<Mutex<Option<PluginEmitHandler>>>,
452    producer_handler: Arc<Mutex<Option<PluginProducerHandler>>>,
453    producer_changes: watch::Sender<u64>,
454}
455
456fn record_plugin_delivery_drop(
457    runtime: &PluginRuntime,
458    drops: &mut Vec<PluginDeliveryDrop>,
459    class: PluginDeliveryClass,
460) {
461    let count = runtime.dropped_events.fetch_add(1, Ordering::AcqRel) + 1;
462    runtime
463        .status
464        .lock()
465        .unwrap_or_else(|poisoned| poisoned.into_inner())
466        .dropped_events = count;
467    drops.push(PluginDeliveryDrop {
468        plugin: runtime.name.clone(),
469        class,
470    });
471}
472
473#[derive(Clone)]
474pub struct PluginRegistry {
475    inner: Arc<PluginRegistryInner>,
476}
477
478impl std::fmt::Debug for PluginRegistry {
479    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        formatter
481            .debug_struct("PluginRegistry")
482            .finish_non_exhaustive()
483    }
484}
485
486pub(crate) fn plugin_event_origin(name: &str) -> cookie_agent_protocol::EventOrigin {
487    cookie_agent_protocol::EventOrigin::new(format!("plugin:{name}")).unwrap_or_else(|_| {
488        cookie_agent_protocol::EventOrigin::new(format!(
489            "plugin:{}",
490            cookie_agent_protocol::Sha256Digest::of_bytes(name.as_bytes())
491        ))
492        .expect("SHA-256 digest is a valid event origin slug")
493    })
494}
495
496impl PluginRegistry {
497    pub(crate) fn new(
498        plugins: IndexMap<String, PluginConfig>,
499        mcp: Arc<crate::McpRegistry>,
500    ) -> Self {
501        let ready = Arc::new(Notify::new());
502        let emit_handler = Arc::new(Mutex::new(None));
503        let producer_handler = Arc::new(Mutex::new(None));
504        let (producer_changes, _) = watch::channel(0);
505        let plugins: IndexMap<String, Arc<PluginRuntime>> = plugins
506            .into_iter()
507            .map(|(name, config)| {
508                let state = if config.enabled {
509                    PluginState::Disconnected
510                } else {
511                    PluginState::Disabled
512                };
513                let runtime = Arc::new(PluginRuntime {
514                    name: name.clone(),
515                    producer_connection: Mutex::new(ProducerConnection {
516                        epoch: None,
517                        live: false,
518                        recovery: config.producer_messaging.then_some(if config.enabled {
519                            PluginRecoveryStatus::Starting
520                        } else {
521                            PluginRecoveryStatus::Disabled
522                        }),
523                        outcome: None,
524                    }),
525                    producer_handler: Arc::clone(&producer_handler),
526                    producer_changes: producer_changes.clone(),
527                    config,
528                    status: Mutex::new(PluginStatus {
529                        plugin: name.clone(),
530                        state,
531                        reason: None,
532                        tools: Vec::new(),
533                        dropped_events: 0,
534                    }),
535                    control: Mutex::new(None),
536                    notifications: Mutex::new(Arc::new(PluginNotificationQueue::new(
537                        PLUGIN_NOTIFICATION_CAPACITY,
538                    ))),
539                    declarations: Mutex::new(Vec::new()),
540                    capabilities: Mutex::new(None),
541                    dropped_events: AtomicU64::new(0),
542                    forced_failure: Mutex::new(None),
543                    forced_failure_notify: Notify::new(),
544                    ready: Arc::clone(&ready),
545                    mcp: Arc::clone(&mcp),
546                    emit_handler: Arc::clone(&emit_handler),
547                    contexts: Mutex::new(PluginContexts::default()),
548                    publish_quotas: Mutex::new(HashMap::new()),
549                });
550                (name, runtime)
551            })
552            .collect();
553        let inner = Arc::new(PluginRegistryInner {
554            plugins,
555            tasks: Mutex::new(Vec::new()),
556            ready,
557            emit_handler,
558            producer_handler,
559            producer_changes,
560        });
561        let weak = Arc::downgrade(&inner);
562        mcp.set_plugin_collision_handler(Arc::new(move |plugin, tool, replacement| {
563            let Some(inner) = weak.upgrade() else {
564                return;
565            };
566            let Some(runtime) = inner.plugins.get(plugin) else {
567                return;
568            };
569            if let Some(replacement) = replacement {
570                let mut status = runtime
571                    .status
572                    .lock()
573                    .unwrap_or_else(|poisoned| poisoned.into_inner());
574                status.tools.retain(|name| name != tool);
575                status.reason = Some(format!(
576                    "tool `{tool}` was replaced by later plugin `{replacement}`"
577                ));
578                return;
579            }
580            *runtime
581                .forced_failure
582                .lock()
583                .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(format!(
584                "plugin `{plugin}` tool `{tool}` was preempted by MCP"
585            ));
586            runtime.forced_failure_notify.notify_one();
587        }));
588        Self { inner }
589    }
590
591    pub(crate) fn start_eager(&self, runtime: &tokio::runtime::Handle) {
592        for plugin in self
593            .inner
594            .plugins
595            .values()
596            .filter(|plugin| plugin.config.enabled)
597        {
598            let plugin = Arc::clone(plugin);
599            let (control, receiver) = mpsc::channel(PLUGIN_OUTBOUND_CAPACITY);
600            let notifications = plugin
601                .notifications
602                .lock()
603                .unwrap_or_else(|poisoned| poisoned.into_inner())
604                .clone();
605            *plugin
606                .control
607                .lock()
608                .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(control);
609            let task = runtime.spawn(async move {
610                plugin.run(receiver, notifications).await;
611            });
612            self.inner
613                .tasks
614                .lock()
615                .unwrap_or_else(|poisoned| poisoned.into_inner())
616                .push(task);
617        }
618    }
619
620    pub(crate) async fn await_eager_ready(&self) {
621        loop {
622            let notified = self.inner.ready.notified();
623            let ready = self.inner.plugins.values().all(|plugin| {
624                !plugin.config.enabled
625                    || matches!(
626                        plugin
627                            .status
628                            .lock()
629                            .unwrap_or_else(|poisoned| poisoned.into_inner())
630                            .state,
631                        PluginState::Connected | PluginState::Failed
632                    )
633            });
634            if ready {
635                return;
636            }
637            notified.await;
638        }
639    }
640
641    #[must_use]
642    pub fn statuses(&self) -> Vec<PluginStatus> {
643        self.inner
644            .plugins
645            .values()
646            .map(|plugin| {
647                plugin
648                    .status
649                    .lock()
650                    .unwrap_or_else(|poisoned| poisoned.into_inner())
651                    .clone()
652            })
653            .collect()
654    }
655
656    pub async fn ping(&self, name: &str) -> Result<(), String> {
657        let plugin = self
658            .inner
659            .plugins
660            .get(name)
661            .ok_or_else(|| format!("unknown plugin `{name}`"))?;
662        let sender = plugin
663            .control
664            .lock()
665            .unwrap_or_else(|poisoned| poisoned.into_inner())
666            .clone()
667            .ok_or_else(|| format!("plugin `{name}` is not running"))?;
668        let (reply, receive) = oneshot::channel();
669        sender
670            .send(Control::Ping(reply))
671            .await
672            .map_err(|_| format!("plugin `{name}` is not running"))?;
673        receive
674            .await
675            .map_err(|_| format!("plugin `{name}` stopped during ping"))?
676    }
677
678    pub(crate) fn set_emit_handler(&self, handler: PluginEmitHandler) {
679        *self
680            .inner
681            .emit_handler
682            .lock()
683            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(handler);
684    }
685
686    pub(crate) fn set_producer_handler(&self, handler: PluginProducerHandler) {
687        *self
688            .inner
689            .producer_handler
690            .lock()
691            .unwrap_or_else(|p| p.into_inner()) = Some(handler);
692    }
693
694    /// Coalescing wakeups, not a lifecycle event log. On every change reconcile
695    /// readiness and revoke registrations whose name/epoch is no longer current.
696    pub(crate) fn subscribe_producer_changes(&self) -> watch::Receiver<u64> {
697        self.inner.producer_changes.subscribe()
698    }
699
700    /// Snapshot of configured or declared producer plugins, including unavailable
701    /// ones. Failed/disabled external work is unknown, never proof of completion.
702    #[must_use]
703    pub fn producer_recovery_states(&self) -> Vec<PluginRecoveryState> {
704        self.inner
705            .plugins
706            .values()
707            .filter_map(|plugin| {
708                let connection = plugin
709                    .producer_connection
710                    .lock()
711                    .unwrap_or_else(|p| p.into_inner());
712                connection.recovery.map(|status| PluginRecoveryState {
713                    plugin: plugin.name.clone(),
714                    status,
715                })
716            })
717            .collect()
718    }
719
720    /// Runtime actor operations must also check registration owner and session.
721    pub(crate) fn producer_connection_is_current(&self, plugin: &str, epoch: &u64) -> bool {
722        self.inner.plugins.get(plugin).is_some_and(|plugin| {
723            let connection = plugin
724                .producer_connection
725                .lock()
726                .unwrap_or_else(|p| p.into_inner());
727            connection.live && connection.epoch == Some(*epoch)
728        })
729    }
730
731    /// Call inside the runtime RecoveryComplete handler before reconciling actors
732    /// and acknowledging. Exact repeats are idempotent; conflicting outcomes fail.
733    pub(crate) fn complete_producer_recovery(
734        &self,
735        authority: &PluginConnectionAuthority,
736        outcome: &ExtensionRecoveryOutcome,
737    ) -> Result<(), JsonRpcError> {
738        let plugin = self
739            .inner
740            .plugins
741            .get(&authority.plugin)
742            .ok_or_else(|| producer_error(-32600, "unknown plugin connection"))?;
743        let mut connection = plugin
744            .producer_connection
745            .lock()
746            .unwrap_or_else(|p| p.into_inner());
747        if !connection.live || connection.epoch != Some(authority.connection_epoch) {
748            return Err(producer_error(-32600, "stale plugin connection"));
749        }
750        if let Some(previous) = &connection.outcome {
751            return if previous == outcome {
752                Ok(())
753            } else {
754                Err(producer_error(-32600, "recovery outcome already reported"))
755            };
756        }
757        connection.recovery = Some(match outcome {
758            ExtensionRecoveryOutcome::Ready => PluginRecoveryStatus::Ready,
759            ExtensionRecoveryOutcome::Failed { .. } => PluginRecoveryStatus::Failed,
760        });
761        connection.outcome = Some(outcome.clone());
762        drop(connection);
763        if let ExtensionRecoveryOutcome::Failed { message } = outcome {
764            self.note_offender_diagnostic(&authority.plugin, message.to_string());
765        }
766        plugin
767            .producer_changes
768            .send_modify(|revision| *revision = revision.wrapping_add(1));
769        Ok(())
770    }
771
772    pub(crate) fn check_publish_quota(
773        &self,
774        plugin: &str,
775        session_id: cookie_agent_protocol::SessionId,
776        event_bytes: usize,
777    ) -> Result<(), &'static str> {
778        let runtime = self
779            .inner
780            .plugins
781            .get(plugin)
782            .ok_or("plugin is not registered")?;
783        let now = Instant::now();
784        let mut quotas = runtime
785            .publish_quotas
786            .lock()
787            .unwrap_or_else(|poisoned| poisoned.into_inner());
788        let quota = quotas.entry(session_id).or_insert(PublishQuota {
789            second_started: now,
790            minute_started: now,
791            events_this_second: 0,
792            bytes_this_minute: 0,
793        });
794        if now.duration_since(quota.second_started) >= Duration::from_secs(1) {
795            quota.second_started = now;
796            quota.events_this_second = 0;
797        }
798        if now.duration_since(quota.minute_started) >= Duration::from_secs(60) {
799            quota.minute_started = now;
800            quota.bytes_this_minute = 0;
801        }
802        if quota.events_this_second >= PLUGIN_EVENTS_PER_SECOND {
803            return Err("plugin event rate exceeds 40 events per second");
804        }
805        if quota.bytes_this_minute.saturating_add(event_bytes) > PLUGIN_BYTES_PER_MINUTE {
806            return Err("plugin event volume exceeds 4 MiB per minute");
807        }
808        quota.events_this_second += 1;
809        quota.bytes_this_minute += event_bytes;
810        Ok(())
811    }
812
813    pub(crate) fn note_offender_diagnostic(&self, plugin: &str, reason: String) {
814        if let Some(runtime) = self.inner.plugins.get(plugin) {
815            runtime
816                .status
817                .lock()
818                .unwrap_or_else(|poisoned| poisoned.into_inner())
819                .reason = Some(reason);
820        }
821    }
822
823    pub(crate) fn stream_session_event(
824        &self,
825        event: &cookie_agent_protocol::StoredEvent,
826        origin: Option<&cookie_agent_protocol::EventOrigin>,
827    ) -> Vec<PluginDeliveryDrop> {
828        let context_id = plugin_context_id();
829        let source_plugin = origin.and_then(|origin| {
830            self.inner
831                .plugins
832                .keys()
833                .find(|name| plugin_event_origin(name) == *origin)
834                .map(String::as_str)
835        });
836        let params = ExtensionEventParams {
837            session_id: event.session_id,
838            context_id: context_id.clone(),
839            seq: event.seq,
840            event: event.payload.clone(),
841            timestamp: event.timestamp,
842        };
843        let notification = Notification::new(
844            PLUGIN_EVENT_METHOD,
845            Some(serde_json::to_value(params).expect("plugin event params serialize")),
846        );
847        let class = match &event.payload {
848            cookie_agent_protocol::EventPayload::ToolCallProgress {
849                display: Some(_), ..
850            } => PluginDeliveryClass::Chunk,
851            cookie_agent_protocol::EventPayload::ToolCallTerminated { .. } => {
852                PluginDeliveryClass::Terminal
853            }
854            _ => PluginDeliveryClass::Ordinary,
855        };
856        self.stream_notification(
857            notification,
858            event.session_id,
859            &context_id,
860            source_plugin,
861            class,
862            |capabilities| capabilities.subscribe_events,
863        )
864    }
865
866    pub(crate) fn stream_bus_event(
867        &self,
868        event: &ExtensionBusEventParams,
869        source_plugin: Option<&str>,
870    ) -> Vec<PluginDeliveryDrop> {
871        let context_id = plugin_context_id();
872        let mut event = event.clone();
873        event.context_id = Some(context_id.clone());
874        let notification = Notification::new(
875            PLUGIN_BUS_EVENT_METHOD,
876            Some(serde_json::to_value(&event).expect("plugin bus event params serialize")),
877        );
878        self.stream_notification(
879            notification,
880            event.session_id,
881            &context_id,
882            source_plugin,
883            PluginDeliveryClass::Ordinary,
884            |capabilities| capabilities.subscribe_bus,
885        )
886    }
887
888    fn stream_notification(
889        &self,
890        notification: Notification,
891        session_id: cookie_agent_protocol::SessionId,
892        context_id: &str,
893        source_plugin: Option<&str>,
894        class: PluginDeliveryClass,
895        subscribed: impl Fn(&ExtensionPluginCapabilities) -> bool,
896    ) -> Vec<PluginDeliveryDrop> {
897        let mut drops = Vec::new();
898        for runtime in self.inner.plugins.values() {
899            if source_plugin == Some(runtime.name.as_str()) {
900                continue;
901            }
902            let enabled = runtime
903                .capabilities
904                .lock()
905                .unwrap_or_else(|poisoned| poisoned.into_inner())
906                .as_ref()
907                .is_some_and(&subscribed);
908            if !enabled {
909                continue;
910            }
911            if runtime
912                .control
913                .lock()
914                .unwrap_or_else(|poisoned| poisoned.into_inner())
915                .is_none()
916            {
917                continue;
918            }
919            let notifications = runtime
920                .notifications
921                .lock()
922                .unwrap_or_else(|poisoned| poisoned.into_inner())
923                .clone();
924            if let Some(dropped) = notifications.push(
925                Control::Notify {
926                    notification: notification.clone(),
927                    session_id,
928                    context_id: context_id.to_owned(),
929                    context_lifetime: PLUGIN_NOTIFICATION_CONTEXT_LIFETIME,
930                },
931                class,
932            ) {
933                record_plugin_delivery_drop(runtime, &mut drops, dropped);
934            }
935        }
936        drops
937    }
938
939    pub(crate) fn interception_plugins(&self, hook: ExtensionInterceptionHook) -> Vec<String> {
940        self.inner
941            .plugins
942            .values()
943            .filter(|runtime| {
944                runtime
945                    .capabilities
946                    .lock()
947                    .unwrap_or_else(|poisoned| poisoned.into_inner())
948                    .as_ref()
949                    .is_some_and(|capabilities| capabilities.intercept.contains(&hook))
950            })
951            .map(|runtime| runtime.name.clone())
952            .collect()
953    }
954
955    pub(crate) async fn intercept_named<P, R>(
956        &self,
957        plugin: &str,
958        method: &'static str,
959        params: &P,
960        session_id: Option<cookie_agent_protocol::SessionId>,
961        context_id: Option<&str>,
962    ) -> Result<R, String>
963    where
964        P: serde::Serialize,
965        R: serde::de::DeserializeOwned,
966    {
967        let runtime = self
968            .inner
969            .plugins
970            .get(plugin)
971            .ok_or_else(|| format!("unknown plugin `{plugin}`"))?;
972        let params = serde_json::to_value(params).expect("interception params serialize");
973        self.intercept_runtime(runtime, method, params, session_id, context_id)
974            .await
975    }
976
977    async fn intercept_runtime<R>(
978        &self,
979        runtime: &Arc<PluginRuntime>,
980        method: &'static str,
981        params: Value,
982        session_id: Option<cookie_agent_protocol::SessionId>,
983        context_id: Option<&str>,
984    ) -> Result<R, String>
985    where
986        R: serde::de::DeserializeOwned,
987    {
988        let sender = runtime
989            .control
990            .lock()
991            .unwrap_or_else(|poisoned| poisoned.into_inner())
992            .clone()
993            .ok_or_else(|| "plugin is not connected".to_owned())?;
994        let (reply, receive) = oneshot::channel();
995        let context_lifetime = context_id.map(|_| {
996            Duration::from_millis(runtime.config.interception_timeout_ms.saturating_add(100))
997        });
998        sender
999            .try_send(Control::Intercept {
1000                method,
1001                params,
1002                session_id,
1003                context_id: context_id.map(str::to_owned),
1004                context_lifetime,
1005                reply,
1006            })
1007            .map_err(|_| "plugin interception queue is full".to_owned())?;
1008        let _lease = match (session_id, context_id) {
1009            (Some(session_id), Some(context_id)) => Some(PluginContextLease {
1010                runtime: Arc::clone(runtime),
1011                context_id: context_id.to_owned(),
1012                session_id,
1013            }),
1014            _ => None,
1015        };
1016        match tokio::time::timeout(
1017            Duration::from_millis(runtime.config.interception_timeout_ms + 50),
1018            receive,
1019        )
1020        .await
1021        {
1022            Ok(Ok(Ok(value))) => serde_json::from_value(value)
1023                .map_err(|error| format!("malformed interception result: {error}")),
1024            Ok(Ok(Err(error))) => Err(error),
1025            Ok(Err(_)) => Err("plugin crashed during interception".into()),
1026            Err(_) => Err("plugin interception timed out".into()),
1027        }
1028    }
1029
1030    #[cfg(test)]
1031    pub(crate) async fn intercept_tool_before_call(
1032        &self,
1033        params: &ExtensionToolBeforeCallParams,
1034    ) -> Vec<(String, Result<ExtensionToolBeforeCallResult, String>)> {
1035        let mut results = Vec::new();
1036        for plugin in self.interception_plugins(ExtensionInterceptionHook::ToolBeforeCall) {
1037            let result = self
1038                .intercept_named::<_, ExtensionToolBeforeCallResult>(
1039                    &plugin,
1040                    PLUGIN_INTERCEPT_TOOL_BEFORE_CALL_METHOD,
1041                    params,
1042                    Some(params.session_id),
1043                    Some(&params.context_id),
1044                )
1045                .await;
1046            let blocked = result.as_ref().is_ok_and(|result| {
1047                result.action == cookie_agent_protocol::ExtensionToolBeforeCallAction::Block
1048            });
1049            results.push((plugin, result));
1050            if blocked {
1051                break;
1052            }
1053        }
1054        results
1055    }
1056
1057    #[cfg(test)]
1058    pub(crate) async fn intercept_tool_after_result(
1059        &self,
1060        params: &ExtensionToolAfterResultParams,
1061    ) -> Vec<(String, Result<ExtensionToolAfterResultResult, String>)> {
1062        let mut chained = params.clone();
1063        let mut results = Vec::new();
1064        for plugin in self.interception_plugins(ExtensionInterceptionHook::ToolAfterResult) {
1065            let result = self
1066                .intercept_named::<_, ExtensionToolAfterResultResult>(
1067                    &plugin,
1068                    PLUGIN_INTERCEPT_TOOL_AFTER_RESULT_METHOD,
1069                    &chained,
1070                    Some(params.session_id),
1071                    Some(&params.context_id),
1072                )
1073                .await;
1074            if let Ok(result) = &result
1075                && result.action == cookie_agent_protocol::ExtensionToolAfterResultAction::Replace
1076                && let Some(replacement) = &result.replacement_content
1077            {
1078                chained.result_content = replacement.clone();
1079            }
1080            results.push((plugin, result));
1081        }
1082        results
1083    }
1084
1085    #[cfg(test)]
1086    pub(crate) async fn intercept_agent_before_start(
1087        &self,
1088        params: &ExtensionAgentBeforeStartParams,
1089    ) -> Vec<(String, Result<ExtensionAgentBeforeStartResult, String>)> {
1090        let mut chained = params.clone();
1091        let mut results = Vec::new();
1092        for plugin in self.interception_plugins(ExtensionInterceptionHook::AgentBeforeStart) {
1093            let result = self
1094                .intercept_named::<_, ExtensionAgentBeforeStartResult>(
1095                    &plugin,
1096                    PLUGIN_INTERCEPT_AGENT_BEFORE_START_METHOD,
1097                    &chained,
1098                    Some(params.session_id),
1099                    Some(&params.context_id),
1100                )
1101                .await;
1102            if let Ok(result) = &result {
1103                if let Some(replacement) = result
1104                    .replace_system_prompt
1105                    .as_ref()
1106                    .filter(|value| !value.is_empty())
1107                {
1108                    chained.prompt_context["system_prompt"] = Value::String(replacement.clone());
1109                }
1110                if let Some(addendum) = result
1111                    .append_to_system_prompt
1112                    .as_ref()
1113                    .or(result.addendum.as_ref())
1114                    .filter(|value| !value.is_empty())
1115                    && let Some(system_prompt) = chained
1116                        .prompt_context
1117                        .get("system_prompt")
1118                        .and_then(Value::as_str)
1119                        .map(str::to_owned)
1120                {
1121                    chained.prompt_context["system_prompt"] =
1122                        Value::String(format!("{system_prompt}\n{addendum}"));
1123                }
1124                if let Some(message) = &result.inject_message {
1125                    let messages = chained
1126                        .prompt_context
1127                        .as_object_mut()
1128                        .expect("prompt context is an object")
1129                        .entry("injected_messages")
1130                        .or_insert_with(|| Value::Array(Vec::new()));
1131                    if let Some(messages) = messages.as_array_mut() {
1132                        messages.push(serde_json::to_value(message).expect("message serializes"));
1133                    }
1134                }
1135            }
1136            results.push((plugin, result));
1137        }
1138        results
1139    }
1140
1141    #[cfg(test)]
1142    pub(crate) async fn intercept_session_before_compact(
1143        &self,
1144        params: &ExtensionSessionBeforeCompactParams,
1145    ) -> Vec<(String, Result<ExtensionSessionBeforeCompactResult, String>)> {
1146        let mut chained = params.clone();
1147        let mut results = Vec::new();
1148        for plugin in self.interception_plugins(ExtensionInterceptionHook::SessionBeforeCompact) {
1149            let result = self
1150                .intercept_named::<_, ExtensionSessionBeforeCompactResult>(
1151                    &plugin,
1152                    PLUGIN_INTERCEPT_SESSION_BEFORE_COMPACT_METHOD,
1153                    &chained,
1154                    Some(params.session_id),
1155                    Some(&params.context_id),
1156                )
1157                .await;
1158            if let Ok(result) = &result
1159                && let Some(addendum) = result.addendum.clone().filter(|value| !value.is_empty())
1160            {
1161                chained.additions.push(addendum);
1162            }
1163            if let Ok(result) = &result
1164                && let Some(instructions) = result.instructions_override.clone()
1165            {
1166                chained.instructions = Some(instructions);
1167            }
1168            results.push((plugin, result));
1169            if results
1170                .last()
1171                .is_some_and(|(_, result)| result.as_ref().is_ok_and(|result| result.cancel))
1172            {
1173                break;
1174            }
1175        }
1176        results
1177    }
1178
1179    fn resolve_tool(
1180        &self,
1181        tool: &str,
1182    ) -> Result<(Arc<PluginRuntime>, ExtensionToolDeclaration), ToolError> {
1183        for runtime in self.inner.plugins.values() {
1184            if !runtime.mcp.plugin_owns_tool(&runtime.name, tool) {
1185                continue;
1186            }
1187            let declaration = runtime
1188                .declarations
1189                .lock()
1190                .unwrap_or_else(|poisoned| poisoned.into_inner())
1191                .iter()
1192                .find(|declaration| declaration.name == tool)
1193                .cloned();
1194            if let Some(declaration) = declaration {
1195                return Ok((Arc::clone(runtime), declaration));
1196            }
1197        }
1198        Err(ToolError::execution(format!(
1199            "plugin tool `{tool}` is no longer available"
1200        )))
1201    }
1202
1203    async fn call_tool(
1204        &self,
1205        plugin: &str,
1206        params: ExtensionToolCallParams,
1207    ) -> Result<ExtensionToolCallResult, ToolError> {
1208        let (runtime, _) = self.resolve_tool(&params.tool)?;
1209        if runtime.name != plugin {
1210            return Err(ToolError::operation_changed(
1211                "plugin tool ownership changed after approval",
1212            ));
1213        }
1214        let sender = runtime
1215            .control
1216            .lock()
1217            .unwrap_or_else(|poisoned| poisoned.into_inner())
1218            .clone()
1219            .ok_or_else(|| ToolError::execution(format!("plugin `{plugin}` is not running")))?;
1220        let (reply, receive) = oneshot::channel();
1221        let context_id = params.context_id.clone();
1222        let session_id = params.session_id;
1223        let context_lifetime =
1224            Duration::from_millis(runtime.config.tool_timeout_ms.saturating_add(100));
1225        sender
1226            .send(Control::ToolCall {
1227                params,
1228                context_lifetime,
1229                reply,
1230            })
1231            .await
1232            .map_err(|_| ToolError::execution(format!("plugin `{plugin}` is not running")))?;
1233        let _lease = PluginContextLease {
1234            runtime: Arc::clone(&runtime),
1235            context_id: context_id.clone(),
1236            session_id,
1237        };
1238        match receive.await {
1239            Ok(result) => result.map_err(ToolError::execution),
1240            Err(_) => Err(ToolError::execution(format!(
1241                "plugin `{plugin}` stopped during tool call"
1242            ))),
1243        }
1244    }
1245
1246    pub async fn shutdown(&self) {
1247        let task_timeout = self
1248            .inner
1249            .plugins
1250            .values()
1251            .map(|plugin| plugin.config.shutdown_grace_ms)
1252            .max()
1253            .unwrap_or(0)
1254            .saturating_add(6_000);
1255        let senders = self
1256            .inner
1257            .plugins
1258            .values()
1259            .filter_map(|plugin| {
1260                plugin
1261                    .control
1262                    .lock()
1263                    .unwrap_or_else(|poisoned| poisoned.into_inner())
1264                    .clone()
1265            })
1266            .collect::<Vec<_>>();
1267        for sender in senders {
1268            let _ = sender.try_send(Control::Shutdown);
1269        }
1270        let tasks = self
1271            .inner
1272            .tasks
1273            .lock()
1274            .map(|mut tasks| tasks.drain(..).collect::<Vec<_>>())
1275            .unwrap_or_default();
1276        for mut task in tasks {
1277            if tokio::time::timeout(Duration::from_millis(task_timeout), &mut task)
1278                .await
1279                .is_err()
1280            {
1281                task.abort();
1282                let _ = task.await;
1283            }
1284        }
1285    }
1286}
1287
1288impl PluginRuntime {
1289    fn register_context(
1290        &self,
1291        context_id: &str,
1292        session_id: cookie_agent_protocol::SessionId,
1293        expires_at: Instant,
1294    ) {
1295        let now = Instant::now();
1296        let mut contexts = self
1297            .contexts
1298            .lock()
1299            .unwrap_or_else(|poisoned| poisoned.into_inner());
1300        prune_plugin_contexts(&mut contexts, now);
1301        if contexts
1302            .spent
1303            .iter()
1304            .any(|context| context.id == context_id)
1305        {
1306            return;
1307        }
1308        if expires_at <= now {
1309            remember_spent_context(&mut contexts, context_id.to_owned(), session_id, now);
1310            return;
1311        }
1312        contexts.active.push_back(PluginContextGrant {
1313            id: context_id.to_owned(),
1314            session_id,
1315            expires_at,
1316        });
1317        if contexts.active.len() > PLUGIN_CONTEXT_CAPACITY
1318            && let Some(evicted) = contexts.active.pop_front()
1319        {
1320            remember_spent_context(&mut contexts, evicted.id, evicted.session_id, now);
1321        }
1322    }
1323
1324    fn revoke_context(&self, context_id: &str, session_id: cookie_agent_protocol::SessionId) {
1325        let now = Instant::now();
1326        let mut contexts = self
1327            .contexts
1328            .lock()
1329            .unwrap_or_else(|poisoned| poisoned.into_inner());
1330        prune_plugin_contexts(&mut contexts, now);
1331        if let Some(index) = contexts
1332            .active
1333            .iter()
1334            .position(|context| context.id == context_id)
1335            && let Some(context) = contexts.active.remove(index)
1336        {
1337            remember_spent_context(&mut contexts, context.id, context.session_id, now);
1338        } else if !contexts
1339            .spent
1340            .iter()
1341            .any(|context| context.id == context_id)
1342        {
1343            remember_spent_context(&mut contexts, context_id.to_owned(), session_id, now);
1344        }
1345    }
1346
1347    fn consume_context(
1348        &self,
1349        context_id: &str,
1350        session_id: cookie_agent_protocol::SessionId,
1351    ) -> PluginEmitContext {
1352        let now = Instant::now();
1353        let mut contexts = self
1354            .contexts
1355            .lock()
1356            .unwrap_or_else(|poisoned| poisoned.into_inner());
1357        prune_plugin_contexts(&mut contexts, now);
1358        if let Some(index) = contexts
1359            .active
1360            .iter()
1361            .position(|context| context.id == context_id)
1362            && let Some(context) = contexts.active.remove(index)
1363        {
1364            let granted = context.session_id == session_id;
1365            let diagnostic_session_id = context.session_id;
1366            remember_spent_context(&mut contexts, context.id, context.session_id, now);
1367            return if granted {
1368                PluginEmitContext::Granted
1369            } else {
1370                PluginEmitContext::Rejected {
1371                    diagnostic_session_id: Some(diagnostic_session_id),
1372                    reason: "session_id does not match the delivered context".into(),
1373                }
1374            };
1375        }
1376        if let Some(context) = contexts
1377            .spent
1378            .iter()
1379            .find(|context| context.id == context_id)
1380        {
1381            return PluginEmitContext::Rejected {
1382                diagnostic_session_id: Some(context.session_id),
1383                reason: "context_id was already consumed, expired, or revoked".into(),
1384            };
1385        }
1386        PluginEmitContext::Rejected {
1387            diagnostic_session_id: None,
1388            reason: "context_id is unknown to this plugin".into(),
1389        }
1390    }
1391
1392    async fn run(
1393        self: Arc<Self>,
1394        receiver: mpsc::Receiver<Control>,
1395        notifications: Arc<PluginNotificationQueue>,
1396    ) {
1397        static NEXT_CONNECTION_EPOCH: AtomicU64 = AtomicU64::new(1);
1398        let epoch = NEXT_CONNECTION_EPOCH
1399            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |epoch| {
1400                epoch.checked_add(1)
1401            })
1402            .expect("plugin connection epoch space exhausted");
1403        {
1404            let mut connection = self
1405                .producer_connection
1406                .lock()
1407                .unwrap_or_else(|p| p.into_inner());
1408            connection.epoch = Some(epoch);
1409            connection.live = false;
1410            connection.outcome = None;
1411        }
1412        let lease = ProducerConnectionLease(Arc::clone(&self), epoch);
1413        self.set_status(PluginState::Connecting, None, Vec::new());
1414        let result = self.spawn_and_supervise(receiver, &notifications).await;
1415        drop(lease);
1416        *self
1417            .control
1418            .lock()
1419            .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
1420        notifications.clear();
1421        *self
1422            .contexts
1423            .lock()
1424            .unwrap_or_else(|poisoned| poisoned.into_inner()) = PluginContexts::default();
1425        self.declarations
1426            .lock()
1427            .unwrap_or_else(|poisoned| poisoned.into_inner())
1428            .clear();
1429        *self
1430            .capabilities
1431            .lock()
1432            .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
1433        self.mcp.release_plugin_tools(&self.name);
1434        match result {
1435            Ok(()) => self.set_status(PluginState::Disconnected, None, Vec::new()),
1436            Err(reason) => self.set_status(PluginState::Failed, Some(reason), Vec::new()),
1437        }
1438    }
1439
1440    async fn spawn_and_supervise(
1441        self: &Arc<Self>,
1442        receiver: mpsc::Receiver<Control>,
1443        notifications: &Arc<PluginNotificationQueue>,
1444    ) -> Result<(), String> {
1445        let epoch = self
1446            .producer_connection
1447            .lock()
1448            .unwrap_or_else(|p| p.into_inner())
1449            .epoch
1450            .expect("supervisor owns a connection epoch");
1451        let command = self
1452            .config
1453            .command
1454            .as_ref()
1455            .expect("enabled plugins are validated");
1456        let mut process = tokio::process::Command::new(command);
1457        process
1458            .args(&self.config.args)
1459            .env_clear()
1460            .envs(&self.config.env)
1461            .stdin(Stdio::piped())
1462            .stdout(Stdio::piped())
1463            .stderr(Stdio::inherit());
1464        if let Some(cwd) = &self.config.cwd {
1465            process.current_dir(cwd);
1466        }
1467        let mut wrapped = CommandWrap::from(process);
1468        #[cfg(unix)]
1469        wrapped.wrap(ProcessGroup::leader());
1470        #[cfg(windows)]
1471        wrapped.wrap(JobObject);
1472        wrapped.wrap(KillOnDrop);
1473        let mut child = wrapped
1474            .spawn()
1475            .map_err(|error| format!("spawn failure for command `{command}`: {error}"))?;
1476        let result = self
1477            .supervise_spawned(&mut child, receiver, notifications)
1478            .await;
1479        // Revoke before process cleanup, which may wait for the shutdown grace.
1480        drop(ProducerConnectionLease(Arc::clone(self), epoch));
1481        let cleanup = terminate_and_reap(&mut child).await;
1482        match (result, cleanup) {
1483            (Err(reason), Err(cleanup)) => Err(format!("{reason}; cleanup failed: {cleanup}")),
1484            (result, Ok(())) => result,
1485            (Ok(()), Err(cleanup)) => Err(format!("cleanup failed: {cleanup}")),
1486        }
1487    }
1488
1489    async fn supervise_spawned(
1490        self: &Arc<Self>,
1491        child: &mut Box<dyn ChildWrapper>,
1492        receiver: mpsc::Receiver<Control>,
1493        notifications: &Arc<PluginNotificationQueue>,
1494    ) -> Result<(), String> {
1495        let stdin = child
1496            .stdin()
1497            .take()
1498            .ok_or_else(|| "spawn failure: child stdin unavailable".to_owned())?;
1499        let stdout = child
1500            .stdout()
1501            .take()
1502            .ok_or_else(|| "spawn failure: child stdout unavailable".to_owned())?;
1503        let (reader_task, inbound) = spawn_reader(stdout);
1504        let result = self
1505            .run_host_loop(child, stdin, receiver, notifications, inbound)
1506            .await;
1507        reader_task.abort();
1508        let _ = reader_task.await;
1509        result
1510    }
1511
1512    async fn run_host_loop(
1513        self: &Arc<Self>,
1514        child: &mut Box<dyn ChildWrapper>,
1515        mut stdin: tokio::process::ChildStdin,
1516        mut control: mpsc::Receiver<Control>,
1517        notifications: &Arc<PluginNotificationQueue>,
1518        mut inbound: mpsc::Receiver<ReaderEvent>,
1519    ) -> Result<(), String> {
1520        let mut initialize = extension_initialize_request(env!("CARGO_PKG_VERSION"));
1521        let mut params: ExtensionInitializeParams =
1522            serde_json::from_value(initialize.params.take().expect("initialize has params"))
1523                .expect("initialize params are typed");
1524        params.capabilities.producer_messaging = self.config.producer_messaging
1525            && self
1526                .producer_handler
1527                .lock()
1528                .unwrap_or_else(|p| p.into_inner())
1529                .is_some();
1530        initialize.params =
1531            Some(serde_json::to_value(params).expect("initialize params serialize"));
1532        write_json(&mut stdin, &initialize).await?;
1533        let mut pending = HashMap::from([(
1534            1_i64,
1535            PendingRequest::Initialize {
1536                deadline: Instant::now() + Duration::from_millis(self.config.startup_timeout_ms),
1537            },
1538        )]);
1539        let mut next_request_id = 2_i64;
1540        let mut connected = false;
1541        let mut producer_tasks = JoinSet::new();
1542
1543        loop {
1544            if let Some(reason) = self
1545                .forced_failure
1546                .lock()
1547                .unwrap_or_else(|poisoned| poisoned.into_inner())
1548                .take()
1549            {
1550                return Err(reason);
1551            }
1552            let deadline = pending.values().map(PendingRequest::deadline).min();
1553            tokio::select! {
1554                status = child.wait() => {
1555                    return Err(match status {
1556                        Ok(status) => format!("process exited with {status}"),
1557                        Err(error) => format!("process wait failed: {error}"),
1558                    });
1559                }
1560                event = inbound.recv() => match event {
1561                    Some(ReaderEvent::Message(message)) => {
1562                        self.dispatch_message(
1563                            &message,
1564                            &mut stdin,
1565                            &mut pending,
1566                            &mut connected,
1567                            &mut producer_tasks,
1568                        ).await?;
1569                    }
1570                    Some(ReaderEvent::Failed(reason)) => return Err(reason),
1571                    Some(ReaderEvent::Eof) | None => return Err("plugin stdout reached EOF".into()),
1572                },
1573                () = notifications.notified() => {
1574                    for notification in notifications.drain() {
1575                        match notification {
1576                            Control::Notify { notification, session_id, context_id, context_lifetime } if connected => {
1577                                self.register_context(
1578                                    &context_id,
1579                                    session_id,
1580                                    Instant::now() + context_lifetime,
1581                                );
1582                                write_json(&mut stdin, &notification).await?;
1583                            }
1584                            Control::Notify { .. } => {}
1585                            _ => unreachable!("notification queue accepts only notifications"),
1586                        }
1587                    }
1588                },
1589                command = control.recv() => match command {
1590                    Some(Control::ReplyFrame(response)) => write_json(&mut stdin, &response).await?,
1591                    Some(Control::Ping(reply)) if connected => {
1592                        let id = next_request_id;
1593                        next_request_id = next_request_id.checked_add(1)
1594                            .ok_or_else(|| "plugin request id space exhausted".to_owned())?;
1595                        let request = Request::new(
1596                            JsonRpcId::Number(id),
1597                            PLUGIN_PING_METHOD,
1598                            Some(serde_json::to_value(ExtensionPingParams {})
1599                                .expect("ping params serialize")),
1600                        );
1601                        write_json(&mut stdin, &request).await?;
1602                        pending.insert(id, PendingRequest::Ping {
1603                            deadline: Instant::now()
1604                                + Duration::from_millis(self.config.interception_timeout_ms),
1605                            reply,
1606                        });
1607                    }
1608                    Some(Control::Ping(reply)) => {
1609                        let _ = reply.send(Err(format!("plugin `{}` is not connected", self.name)));
1610                    }
1611                    Some(Control::ToolCall { params, context_lifetime, reply }) if connected => {
1612                        self.register_context(
1613                            &params.context_id,
1614                            params.session_id,
1615                            Instant::now() + context_lifetime,
1616                        );
1617                        let id = next_request_id;
1618                        next_request_id = next_request_id.checked_add(1)
1619                            .ok_or_else(|| "plugin request id space exhausted".to_owned())?;
1620                        let request = Request::new(
1621                            JsonRpcId::Number(id),
1622                            PLUGIN_TOOLS_CALL_METHOD,
1623                            Some(serde_json::to_value(params)
1624                                .expect("plugin tool call params serialize")),
1625                        );
1626                        write_json(&mut stdin, &request).await?;
1627                        pending.insert(id, PendingRequest::ToolCall {
1628                            deadline: Instant::now()
1629                                + Duration::from_millis(self.config.tool_timeout_ms),
1630                            reply,
1631                        });
1632                    }
1633                    Some(Control::ToolCall { reply, .. }) => {
1634                        let _ = reply.send(Err(format!("plugin `{}` is not connected", self.name)));
1635                    }
1636                    Some(Control::Notify { notification, session_id, context_id, context_lifetime }) if connected => {
1637                        self.register_context(
1638                            &context_id,
1639                            session_id,
1640                            Instant::now() + context_lifetime,
1641                        );
1642                        write_json(&mut stdin, &notification).await?;
1643                    }
1644                    Some(Control::Notify { .. }) => {}
1645                    Some(Control::Intercept { method, params, session_id, context_id, context_lifetime, reply }) if connected => {
1646                        if let (Some(session_id), Some(context_id), Some(context_lifetime)) =
1647                            (session_id, context_id.as_deref(), context_lifetime)
1648                        {
1649                            self.register_context(
1650                                context_id,
1651                                session_id,
1652                                Instant::now() + context_lifetime,
1653                            );
1654                        }
1655                        let id = next_request_id;
1656                        next_request_id = next_request_id.checked_add(1)
1657                            .ok_or_else(|| "plugin request id space exhausted".to_owned())?;
1658                        write_json(&mut stdin, &Request::new(
1659                            JsonRpcId::Number(id), method, Some(params),
1660                        )).await?;
1661                        pending.insert(id, PendingRequest::Intercept {
1662                            deadline: Instant::now()
1663                                + Duration::from_millis(self.config.interception_timeout_ms),
1664                            reply,
1665                        });
1666                    }
1667                    Some(Control::Intercept { reply, .. }) => {
1668                        let _ = reply.send(Err(format!("plugin `{}` is not connected", self.name)));
1669                    }
1670                    Some(Control::Shutdown) | None => {
1671                        return graceful_shutdown(
1672                            child,
1673                            &mut stdin,
1674                            self.config.shutdown_grace_ms,
1675                        ).await;
1676                    }
1677                },
1678                () = self.forced_failure_notify.notified() => {}
1679                completed = producer_tasks.join_next(), if !producer_tasks.is_empty() => {
1680                    completed.expect("task set is nonempty")
1681                        .map_err(|error| format!("plugin producer handler failed: {error}"))?;
1682                }
1683                () = wait_for_deadline(deadline) => {
1684                    let now = Instant::now();
1685                    let expired = pending
1686                        .iter()
1687                        .filter_map(|(id, request)| (request.deadline() <= now).then_some(*id))
1688                        .collect::<Vec<_>>();
1689                    for id in expired {
1690                        match pending.remove(&id).expect("expired pending request exists") {
1691                            PendingRequest::Initialize { .. } => return Err("handshake timeout".into()),
1692                            PendingRequest::Ping { reply, .. } => {
1693                                let _ = reply.send(Err("plugin ping timed out".into()));
1694                            }
1695                            PendingRequest::ToolCall { reply, .. } => {
1696                                let _ = reply.send(Err("plugin tool call timed out".into()));
1697                            }
1698                            PendingRequest::Intercept { reply, .. } => {
1699                                let _ = reply.send(Err("plugin interception timed out".into()));
1700                            }
1701                        }
1702                    }
1703                }
1704            }
1705        }
1706    }
1707
1708    async fn dispatch_message(
1709        self: &Arc<Self>,
1710        message: &str,
1711        stdin: &mut tokio::process::ChildStdin,
1712        pending: &mut HashMap<i64, PendingRequest>,
1713        connected: &mut bool,
1714        producer_tasks: &mut JoinSet<()>,
1715    ) -> Result<(), String> {
1716        let value: Value = serde_json::from_str(message)
1717            .map_err(|error| format!("malformed plugin message: {error}"))?;
1718        if value.get("method").is_some() {
1719            if value.get("id").is_some() {
1720                let request: Request = serde_json::from_value(value)
1721                    .map_err(|error| format!("malformed plugin request: {error}"))?;
1722                if let Some(response) = self.dispatch_producer_request(request, producer_tasks) {
1723                    write_json(stdin, &response).await?;
1724                }
1725            } else {
1726                let notification = serde_json::from_value::<Notification>(value)
1727                    .map_err(|error| format!("malformed plugin notification: {error}"))?;
1728                if notification.method == PLUGIN_EMIT_METHOD {
1729                    let params: ExtensionEmitParams =
1730                        serde_json::from_value(notification.params.unwrap_or(Value::Null))
1731                            .map_err(|error| {
1732                                format!("malformed plugin emit notification: {error}")
1733                            })?;
1734                    let capabilities = self
1735                        .capabilities
1736                        .lock()
1737                        .unwrap_or_else(|poisoned| poisoned.into_inner())
1738                        .clone();
1739                    let handler = self
1740                        .emit_handler()
1741                        .ok_or_else(|| "plugin event publisher is unavailable".to_owned())?;
1742                    let outcome = if let Some(capabilities) = capabilities {
1743                        let context = self.consume_context(&params.context_id, params.session_id);
1744                        handler(PluginEmitRequest {
1745                            plugin: self.name.clone(),
1746                            session_id: params.session_id,
1747                            context,
1748                            name: params.name.clone(),
1749                            payload: params.payload,
1750                            publish_bus: capabilities.publish_bus,
1751                            publish_session_events: capabilities.publish_session_events,
1752                        })
1753                        .await
1754                    } else {
1755                        PluginEmitOutcome {
1756                            bus: ExtensionEmitStatus::Rejected,
1757                            durable: ExtensionEmitStatus::Rejected,
1758                            reason: Some("plugin is not initialized".into()),
1759                        }
1760                    };
1761                    write_json(
1762                        stdin,
1763                        &Notification::new(
1764                            PLUGIN_EMIT_RESULT_METHOD,
1765                            Some(
1766                                serde_json::to_value(ExtensionEmitResultParams {
1767                                    name: params.name,
1768                                    bus: outcome.bus,
1769                                    durable: outcome.durable,
1770                                    reason: outcome.reason,
1771                                })
1772                                .expect("emit result params serialize"),
1773                            ),
1774                        ),
1775                    )
1776                    .await?;
1777                }
1778            }
1779            return Ok(());
1780        }
1781
1782        let response: Response = serde_json::from_value(value)
1783            .map_err(|error| format!("malformed plugin response: {error}"))?;
1784        let id = match &response {
1785            Response::Success(response) => &response.id,
1786            Response::Error(response) => &response.id,
1787        };
1788        let JsonRpcId::Number(id) = id else {
1789            return Ok(());
1790        };
1791        let Some(request) = pending.remove(id) else {
1792            return Ok(());
1793        };
1794        match request {
1795            PendingRequest::Initialize { .. } => {
1796                let initialize = parse_initialize(response, &self.name)?;
1797                validate_tools(&initialize)?;
1798                let names = initialize
1799                    .tools
1800                    .iter()
1801                    .map(|tool| tool.name.clone())
1802                    .collect::<Vec<_>>();
1803                self.mcp
1804                    .claim_plugin_tools(&self.name, &names)
1805                    .map_err(|error| error.to_string())?;
1806                *self
1807                    .declarations
1808                    .lock()
1809                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = initialize.tools;
1810                *self
1811                    .capabilities
1812                    .lock()
1813                    .unwrap_or_else(|poisoned| poisoned.into_inner()) =
1814                    Some(initialize.capabilities);
1815                self.set_status(PluginState::Connected, None, names);
1816                *connected = true;
1817                let capable = self
1818                    .capabilities
1819                    .lock()
1820                    .unwrap_or_else(|p| p.into_inner())
1821                    .as_ref()
1822                    .is_some_and(|capabilities| capabilities.producer_messaging);
1823                let handler_available = self
1824                    .producer_handler
1825                    .lock()
1826                    .unwrap_or_else(|p| p.into_inner())
1827                    .is_some();
1828                {
1829                    let mut connection = self
1830                        .producer_connection
1831                        .lock()
1832                        .unwrap_or_else(|p| p.into_inner());
1833                    connection.live =
1834                        capable && self.config.producer_messaging && handler_available;
1835                    if capable || self.config.producer_messaging {
1836                        connection.recovery = Some(if connection.live {
1837                            PluginRecoveryStatus::Starting
1838                        } else if capable && self.config.producer_messaging {
1839                            PluginRecoveryStatus::Failed
1840                        } else {
1841                            PluginRecoveryStatus::Disabled
1842                        });
1843                    }
1844                }
1845                self.producer_changes
1846                    .send_modify(|revision| *revision = revision.wrapping_add(1));
1847                if capable && self.config.producer_messaging && handler_available {
1848                    write_json(stdin, &extension_recovery_start_notification()).await?;
1849                }
1850                Ok(())
1851            }
1852            PendingRequest::Ping { reply, .. } => {
1853                let result = parse_ping(response);
1854                let _ = reply.send(result);
1855                Ok(())
1856            }
1857            PendingRequest::ToolCall { reply, .. } => {
1858                let result = parse_tool_call(response);
1859                let _ = reply.send(result);
1860                Ok(())
1861            }
1862            PendingRequest::Intercept { reply, .. } => {
1863                let result = match response {
1864                    Response::Success(success) => Ok(success.result),
1865                    Response::Error(error) => Err(format!(
1866                        "plugin interception rejected: {}",
1867                        error.error.message
1868                    )),
1869                };
1870                let _ = reply.send(result);
1871                Ok(())
1872            }
1873        }
1874    }
1875
1876    fn dispatch_producer_request(
1877        self: &Arc<Self>,
1878        request: Request,
1879        tasks: &mut JoinSet<()>,
1880    ) -> Option<Response> {
1881        let id = request.id;
1882        let reject = |error| Some(producer_response(id.clone(), Err(error)));
1883        let decoded =
1884            match decode_producer_request(&request.method, request.params.unwrap_or(Value::Null)) {
1885                Ok(decoded) => decoded,
1886                Err(error) => return reject(error),
1887            };
1888        let authority = {
1889            let connection = self
1890                .producer_connection
1891                .lock()
1892                .unwrap_or_else(|p| p.into_inner());
1893            if !connection.live {
1894                return reject(producer_error(
1895                    -32600,
1896                    "producer messaging is not enabled on this connection",
1897                ));
1898            }
1899            PluginConnectionAuthority {
1900                plugin: self.name.clone(),
1901                connection_epoch: connection.epoch.expect("live connection has an epoch"),
1902            }
1903        };
1904        let Some(handler) = self
1905            .producer_handler
1906            .lock()
1907            .unwrap_or_else(|p| p.into_inner())
1908            .clone()
1909        else {
1910            return reject(producer_error(
1911                -32601,
1912                "plugin producer handler is unavailable",
1913            ));
1914        };
1915        // Never wait for capacity on the reader: callbacks may themselves need
1916        // replies from this connection. Overload is an explicit rejection.
1917        if tasks.len() >= PLUGIN_PRODUCER_CONCURRENCY {
1918            return reject(producer_error(
1919                -32000,
1920                "plugin producer handler capacity exceeded",
1921            ));
1922        }
1923        let Some(control) = self
1924            .control
1925            .lock()
1926            .unwrap_or_else(|p| p.into_inner())
1927            .clone()
1928        else {
1929            return reject(producer_error(-32600, "plugin connection is closed"));
1930        };
1931        let runtime = Arc::clone(self);
1932        tasks.spawn(async move {
1933            let current = || {
1934                let connection = runtime
1935                    .producer_connection
1936                    .lock()
1937                    .unwrap_or_else(|p| p.into_inner());
1938                connection.live && connection.epoch == Some(authority.connection_epoch)
1939            };
1940            let result = if current() {
1941                handler(authority.clone(), decoded)
1942                    .await
1943                    .and_then(|response| producer_result(&request.method, response))
1944            } else {
1945                Err(producer_error(-32600, "stale plugin connection"))
1946            };
1947            let result = if current() {
1948                result
1949            } else {
1950                Err(producer_error(-32600, "stale plugin connection"))
1951            };
1952            // A closed receiver means the transport disconnected; the caller sees
1953            // EOF, not a fabricated ACK. A full queue is awaited without dropping.
1954            let _ = control
1955                .send(Control::ReplyFrame(producer_response(id, result)))
1956                .await;
1957        });
1958        None
1959    }
1960
1961    fn set_status(&self, state: PluginState, reason: Option<String>, tools: Vec<String>) {
1962        let reason =
1963            reason.map(|reason| cookie_agent_protocol::diagnostics::detail(&reason).to_string());
1964        let mut status = self
1965            .status
1966            .lock()
1967            .unwrap_or_else(|poisoned| poisoned.into_inner());
1968        status.state = state;
1969        status.reason = reason;
1970        status.tools = tools;
1971        drop(status);
1972        self.ready.notify_waiters();
1973    }
1974
1975    fn emit_handler(&self) -> Option<PluginEmitHandler> {
1976        self.emit_handler
1977            .lock()
1978            .unwrap_or_else(|poisoned| poisoned.into_inner())
1979            .clone()
1980    }
1981}
1982
1983fn producer_error(code: i32, message: &str) -> JsonRpcError {
1984    JsonRpcError {
1985        code,
1986        message: message.into(),
1987        data: None,
1988    }
1989}
1990
1991fn decode_producer_request(
1992    method: &str,
1993    params: Value,
1994) -> Result<PluginProducerRequest, JsonRpcError> {
1995    let decoded = match method {
1996        PLUGIN_PRODUCER_REGISTER_METHOD => {
1997            serde_json::from_value(params).map(PluginProducerRequest::Register)
1998        }
1999        PLUGIN_PRODUCER_SEND_METHOD => {
2000            serde_json::from_value(params).map(PluginProducerRequest::Send)
2001        }
2002        PLUGIN_PRODUCER_UNREGISTER_METHOD => {
2003            serde_json::from_value(params).map(PluginProducerRequest::Unregister)
2004        }
2005        PLUGIN_PRODUCER_DISCARD_METHOD => {
2006            serde_json::from_value(params).map(PluginProducerRequest::Discard)
2007        }
2008        PLUGIN_RECOVERY_COMPLETE_METHOD => {
2009            serde_json::from_value(params).map(PluginProducerRequest::RecoveryComplete)
2010        }
2011        _ => return Err(producer_error(-32601, "unknown plugin request method")),
2012    };
2013    decoded
2014        .map_err(|error| producer_error(-32602, &format!("invalid producer parameters: {error}")))
2015}
2016
2017fn producer_result(method: &str, response: PluginProducerResponse) -> Result<Value, JsonRpcError> {
2018    let result = match (method, response) {
2019        (PLUGIN_PRODUCER_REGISTER_METHOD, PluginProducerResponse::Register(result)) => {
2020            serde_json::to_value(result)
2021        }
2022        (PLUGIN_PRODUCER_SEND_METHOD, PluginProducerResponse::Send(result)) => {
2023            serde_json::to_value(result)
2024        }
2025        (PLUGIN_PRODUCER_UNREGISTER_METHOD, PluginProducerResponse::Unregister(result)) => {
2026            serde_json::to_value(result)
2027        }
2028        (PLUGIN_PRODUCER_DISCARD_METHOD, PluginProducerResponse::Discard(result)) => {
2029            serde_json::to_value(result)
2030        }
2031        (PLUGIN_RECOVERY_COMPLETE_METHOD, PluginProducerResponse::RecoveryComplete(result)) => {
2032            serde_json::to_value(result)
2033        }
2034        _ => {
2035            return Err(producer_error(
2036                -32603,
2037                "plugin producer handler returned the wrong response variant",
2038            ));
2039        }
2040    };
2041    result.map_err(|error| producer_error(-32603, &error.to_string()))
2042}
2043
2044fn producer_response(id: JsonRpcId, result: Result<Value, JsonRpcError>) -> Response {
2045    match result {
2046        Ok(result) => Response::Success(SuccessResponse {
2047            jsonrpc: JsonRpcVersion::current(),
2048            id,
2049            result,
2050        }),
2051        Err(error) => Response::Error(ErrorResponse {
2052            jsonrpc: JsonRpcVersion::current(),
2053            id,
2054            error,
2055        }),
2056    }
2057}
2058
2059fn prune_plugin_contexts(contexts: &mut PluginContexts, now: Instant) {
2060    let mut active = VecDeque::with_capacity(contexts.active.len());
2061    while let Some(context) = contexts.active.pop_front() {
2062        if context.expires_at <= now {
2063            let expired = context;
2064            remember_spent_context(contexts, expired.id, expired.session_id, now);
2065        } else {
2066            active.push_back(context);
2067        }
2068    }
2069    contexts.active = active;
2070    contexts.spent.retain(|context| context.expires_at > now);
2071}
2072
2073fn remember_spent_context(
2074    contexts: &mut PluginContexts,
2075    id: String,
2076    session_id: cookie_agent_protocol::SessionId,
2077    now: Instant,
2078) {
2079    contexts.spent.push_back(PluginSpentContext {
2080        id,
2081        session_id,
2082        expires_at: now + PLUGIN_SPENT_CONTEXT_LIFETIME,
2083    });
2084    if contexts.spent.len() > PLUGIN_SPENT_CONTEXT_CAPACITY {
2085        contexts.spent.pop_front();
2086    }
2087}
2088
2089pub(crate) fn plugin_context_id() -> String {
2090    uuid::Uuid::now_v7().to_string()
2091}
2092
2093fn spawn_reader(
2094    stdout: tokio::process::ChildStdout,
2095) -> (JoinHandle<()>, mpsc::Receiver<ReaderEvent>) {
2096    let (sender, receiver) = mpsc::channel(32);
2097    let task = tokio::spawn(async move {
2098        let mut frames = FramedRead::new(stdout, LinesCodec::new_with_max_length(MAX_FRAME_BYTES));
2099        while let Some(frame) = frames.next().await {
2100            let event = match frame {
2101                Ok(message) => ReaderEvent::Message(message),
2102                Err(error) => ReaderEvent::Failed(format!(
2103                    "plugin stdout frame is invalid UTF-8 or exceeds {MAX_FRAME_BYTES} bytes: {error}"
2104                )),
2105            };
2106            let failed = matches!(event, ReaderEvent::Failed(_));
2107            if sender.send(event).await.is_err() || failed {
2108                return;
2109            }
2110        }
2111        let _ = sender.send(ReaderEvent::Eof).await;
2112    });
2113    (task, receiver)
2114}
2115
2116fn parse_initialize(
2117    response: Response,
2118    expected_name: &str,
2119) -> Result<ExtensionInitializeResult, String> {
2120    let success = match response {
2121        Response::Success(success) => success,
2122        Response::Error(error) => {
2123            return Err(format!(
2124                "handshake rejected: {}",
2125                cookie_agent_protocol::diagnostics::rpc(&error.error)
2126            ));
2127        }
2128    };
2129    let found_version = success
2130        .result
2131        .get("protocol_version")
2132        .and_then(Value::as_str)
2133        .ok_or_else(|| {
2134            "malformed handshake result: protocol_version must be a string".to_owned()
2135        })?;
2136    if found_version != EXTENSION_PROTOCOL_VERSION {
2137        return Err(format!(
2138            "version mismatch: expected {EXTENSION_PROTOCOL_VERSION}, found {found_version}"
2139        ));
2140    }
2141    let result: ExtensionInitializeResult = serde_json::from_value(success.result)
2142        .map_err(|error| format!("malformed handshake result: {error}"))?;
2143    if result.name != expected_name {
2144        return Err(format!(
2145            "plugin name mismatch: configured `{expected_name}`, reported `{}`",
2146            result.name
2147        ));
2148    }
2149    Ok(result)
2150}
2151
2152fn parse_ping(response: Response) -> Result<(), String> {
2153    match response {
2154        Response::Success(SuccessResponse { result, .. }) => {
2155            serde_json::from_value::<ExtensionPingResult>(result)
2156                .map_err(|error| format!("malformed plugin ping result: {error}"))?;
2157            Ok(())
2158        }
2159        Response::Error(error) => Err(format!(
2160            "plugin ping rejected: {}",
2161            cookie_agent_protocol::diagnostics::rpc(&error.error)
2162        )),
2163    }
2164}
2165
2166fn parse_tool_call(response: Response) -> Result<ExtensionToolCallResult, String> {
2167    match response {
2168        Response::Success(SuccessResponse { result, .. }) => serde_json::from_value(result)
2169            .map_err(|error| format!("malformed plugin tool call result: {error}")),
2170        Response::Error(error) => Err(format!(
2171            "plugin tool call rejected: {}",
2172            cookie_agent_protocol::diagnostics::rpc(&error.error)
2173        )),
2174    }
2175}
2176
2177#[async_trait]
2178impl ToolProvider for PluginRegistry {
2179    fn provider_id(&self) -> &'static str {
2180        "plugin"
2181    }
2182
2183    fn tools_for_session(&self, _ctx: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
2184        let mut tools = Vec::new();
2185        for runtime in self.inner.plugins.values() {
2186            let declarations = runtime
2187                .declarations
2188                .lock()
2189                .unwrap_or_else(|poisoned| poisoned.into_inner())
2190                .clone();
2191            tools.extend(
2192                declarations
2193                    .into_iter()
2194                    .filter(|tool| runtime.mcp.plugin_owns_tool(&runtime.name, &tool.name))
2195                    .map(|tool| ToolSpec {
2196                        output: tool.output,
2197                        concurrency: Default::default(),
2198                        // External opt-out requires a future extension-protocol capability.
2199                        result_truncation: Default::default(),
2200                        name: tool.name,
2201                        permission_name: format!("plugin:{}", tool.permission_name),
2202                        description: tool.description,
2203                        parameters: tool.parameters,
2204                    }),
2205            );
2206        }
2207        Ok(tools)
2208    }
2209
2210    fn get_permission_name(_tool_name: &str) -> Result<&'static str, ToolError> {
2211        Ok("plugin")
2212    }
2213
2214    fn get_permission_resource(
2215        &self,
2216        tool_name: &str,
2217        arguments: &Value,
2218    ) -> Result<(&'static str, Option<String>), ToolError> {
2219        let (_, declaration) = self.resolve_tool(tool_name)?;
2220        let primary = primary_resource(&declaration, arguments)?;
2221        Ok((
2222            "plugin",
2223            Some(permission_resource(
2224                &declaration.permission_name,
2225                primary.as_deref(),
2226            )),
2227        ))
2228    }
2229
2230    fn get_display_argument(&self, name: &str, arguments: &Value) -> Result<String, ToolError> {
2231        let (_, declaration) = self.resolve_tool(name)?;
2232        Ok(primary_resource(&declaration, arguments)?.unwrap_or_else(|| name.to_owned()))
2233    }
2234
2235    async fn prepare(
2236        &self,
2237        ctx: ToolPreparationContext,
2238        call: ToolCall,
2239    ) -> Result<PreparedTool, ToolError> {
2240        let (runtime, declaration) = self.resolve_tool(&call.name)?;
2241        if !call.arguments.is_object() {
2242            return Err(ToolError::execution(
2243                "plugin tool arguments must be an object",
2244            ));
2245        }
2246        let normalized = call.arguments.clone();
2247        let argument_bytes = serde_json::to_vec(&normalized)
2248            .map_err(|error| ToolError::execution(error.to_string()))?;
2249        let primary = primary_resource(&declaration, &normalized)?;
2250        let permission_label =
2251            permission_resource(&declaration.permission_name, primary.as_deref());
2252        let resource = PreparedApprovalResource {
2253            capability: PermissionAction::Plugin,
2254            canonical: PreparedResourceIdentity::new(format!(
2255                "plugin-tool:{}",
2256                Sha256Digest::of_bytes(permission_label.as_bytes())
2257            ))
2258            .map_err(|error| ToolError::execution(error.to_string()))?,
2259            binding_digest: PreparedResourceDigest::from_canonical_binding_bytes(
2260                permission_label.as_bytes(),
2261            ),
2262            binding_lifetime: PreparedBindingLifetime::RestartStable,
2263            boundary: ApprovalBoundary::Exact,
2264            source: ApprovalResourceSource::PrimaryOperation,
2265        };
2266        let operation = PreparedOperationIdentity::new(
2267            Sha256Digest::of_bytes(&argument_bytes),
2268            vec![ApprovalCapability {
2269                action: PermissionAction::Plugin,
2270                operation: PreparedCapabilityOperation::new(format!(
2271                    "{}:call",
2272                    declaration.permission_name
2273                ))
2274                .map_err(|error| ToolError::execution(error.to_string()))?,
2275            }],
2276            vec![resource],
2277            Sha256Digest::of_bytes(ctx.workspace_root.to_string_lossy().as_bytes()),
2278        )
2279        .map_err(|error| ToolError::execution(error.to_string()))?;
2280        PreparedTool::new(
2281            operation,
2282            normalized,
2283            None,
2284            Box::new(PluginExecutor {
2285                registry: self.clone(),
2286                plugin: runtime.name.clone(),
2287                declaration,
2288                session: ctx.session,
2289                call,
2290                resource: primary,
2291                timeout: Duration::from_millis(runtime.config.tool_timeout_ms),
2292            }),
2293        )
2294    }
2295}
2296
2297struct PluginExecutor {
2298    registry: PluginRegistry,
2299    plugin: String,
2300    declaration: ExtensionToolDeclaration,
2301    session: cookie_agent_protocol::SessionId,
2302    call: ToolCall,
2303    resource: Option<String>,
2304    timeout: Duration,
2305}
2306
2307#[async_trait]
2308impl PreparedExecutor for PluginExecutor {
2309    async fn revalidate(&self) -> Result<(), ToolError> {
2310        let (runtime, declaration) = self.registry.resolve_tool(&self.call.name)?;
2311        if runtime.name != self.plugin || declaration != self.declaration {
2312            return Err(ToolError::operation_changed(
2313                "plugin tool changed after approval",
2314            ));
2315        }
2316        Ok(())
2317    }
2318
2319    async fn execute(
2320        self: Box<Self>,
2321        context: ToolExecutionContext,
2322    ) -> Result<crate::ToolCompletion, ToolError> {
2323        self.revalidate().await?;
2324        let params = ExtensionToolCallParams {
2325            tool: self.call.name.clone(),
2326            session_id: self.session,
2327            context_id: plugin_context_id(),
2328            invocation_id: self.call.id,
2329            arguments: self.call.arguments.clone(),
2330            resource: self.resource.clone(),
2331            cancellation_token: Some(self.call.id.to_string()),
2332        };
2333        let call = self.registry.call_tool(&self.plugin, params);
2334        tokio::pin!(call);
2335        let result = tokio::select! {
2336            result = &mut call => result,
2337            () = context.cancellation.cancelled() => {
2338                Err(ToolError::execution("plugin tool call cancelled"))
2339            }
2340            () = tokio::time::sleep(self.timeout) => {
2341                Err(ToolError::execution("plugin tool call timed out"))
2342            }
2343        }?;
2344        if matches!(
2345            result.output,
2346            cookie_agent_protocol::ToolCompletionOutput::Streamed
2347        ) {
2348            return Err(ToolError::execution(
2349                "plugin transport requires terminal output",
2350            ));
2351        }
2352        Ok(crate::ToolCompletion {
2353            failed: result.is_error,
2354            output: result.output,
2355            result: cookie_agent_protocol::PersistedToolResult {
2356                display: Some(result.display),
2357                retained_output: None,
2358                title: safe_title(&self.call.name),
2359                output: String::new(),
2360                metadata: serde_json::json!({
2361                    "plugin": {
2362                        "is_error": result.is_error,
2363                    }
2364                }),
2365                truncation: None,
2366                attachments: Vec::new(),
2367                additional_messages: Vec::new(),
2368            },
2369        })
2370    }
2371}
2372
2373fn primary_resource(
2374    declaration: &ExtensionToolDeclaration,
2375    arguments: &Value,
2376) -> Result<Option<String>, ToolError> {
2377    let Some(parameter) = &declaration.primary_resource_param else {
2378        return Ok(None);
2379    };
2380    let value = arguments
2381        .as_object()
2382        .and_then(|arguments| arguments.get(parameter));
2383    value
2384        .map(|value| match value {
2385            Value::String(value) => Ok(value.clone()),
2386            value => serde_json::to_string(value)
2387                .map_err(|error| ToolError::execution(error.to_string())),
2388        })
2389        .transpose()
2390}
2391
2392fn permission_resource(permission_name: &str, primary: Option<&str>) -> String {
2393    primary.map_or_else(
2394        || permission_name.to_owned(),
2395        |primary| format!("{permission_name} {primary}"),
2396    )
2397}
2398
2399fn safe_title(value: &str) -> SafeDisplayText {
2400    let mut title = String::new();
2401    for character in value.chars() {
2402        let character = if character.is_control() {
2403            ' '
2404        } else {
2405            character
2406        };
2407        if title.len() + character.len_utf8() > SafeDisplayText::MAX_BYTES {
2408            break;
2409        }
2410        title.push(character);
2411    }
2412    SafeDisplayText::new(title).expect("sanitized plugin title")
2413}
2414
2415fn validate_tools(initialize: &ExtensionInitializeResult) -> Result<(), String> {
2416    let mut names = HashSet::new();
2417    for tool in &initialize.tools {
2418        if !is_snake_case(&tool.name) || !names.insert(&tool.name) {
2419            return Err(format!(
2420                "plugin declared invalid or duplicate tool name `{}`",
2421                tool.name
2422            ));
2423        }
2424        if tool.description.is_empty() || !is_snake_case(&tool.permission_name) {
2425            return Err(format!("plugin tool `{}` is malformed", tool.name));
2426        }
2427        let schema = JsonSchema::new(tool.parameters.clone()).map_err(|error| {
2428            format!(
2429                "plugin tool `{}` has invalid JSON Schema: {error}",
2430                tool.name
2431            )
2432        })?;
2433        jsonschema::draft202012::meta::validate(schema.as_value()).map_err(|error| {
2434            format!(
2435                "plugin tool `{}` has invalid JSON Schema: {error}",
2436                tool.name
2437            )
2438        })?;
2439        let properties = schema
2440            .as_value()
2441            .as_object()
2442            .and_then(|schema| schema.get("properties"))
2443            .and_then(Value::as_object);
2444        if let Some(parameter) = &tool.primary_resource_param
2445            && !properties.is_some_and(|properties| properties.contains_key(parameter))
2446        {
2447            return Err(format!(
2448                "plugin tool `{}` primary_resource_param `{parameter}` is not declared in properties",
2449                tool.name
2450            ));
2451        }
2452    }
2453    Ok(())
2454}
2455
2456fn is_snake_case(value: &str) -> bool {
2457    !value.is_empty()
2458        && value.split('_').all(|word| {
2459            !word.is_empty()
2460                && word
2461                    .bytes()
2462                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
2463        })
2464        && value.as_bytes()[0].is_ascii_lowercase()
2465}
2466
2467async fn write_json(
2468    stdin: &mut tokio::process::ChildStdin,
2469    message: &impl serde::Serialize,
2470) -> Result<(), String> {
2471    let mut bytes = serde_json::to_vec(message).map_err(|error| error.to_string())?;
2472    bytes.push(b'\n');
2473    stdin
2474        .write_all(&bytes)
2475        .await
2476        .map_err(|error| format!("plugin write failed: {error}"))?;
2477    stdin
2478        .flush()
2479        .await
2480        .map_err(|error| format!("plugin flush failed: {error}"))
2481}
2482
2483async fn wait_for_deadline(deadline: Option<Instant>) {
2484    match deadline {
2485        Some(deadline) => tokio::time::sleep_until(deadline).await,
2486        None => std::future::pending().await,
2487    }
2488}
2489
2490async fn graceful_shutdown(
2491    child: &mut Box<dyn ChildWrapper>,
2492    stdin: &mut tokio::process::ChildStdin,
2493    grace_ms: u64,
2494) -> Result<(), String> {
2495    let notification: Notification = extension_shutdown_notification();
2496    let _ = write_json(stdin, &notification).await;
2497    let _ = stdin.shutdown().await;
2498    match tokio::time::timeout(Duration::from_millis(grace_ms), child.wait()).await {
2499        Ok(Ok(_)) => Ok(()),
2500        Ok(Err(error)) => Err(format!("plugin wait failed during shutdown: {error}")),
2501        Err(_) => Err("plugin shutdown grace period expired".into()),
2502    }
2503}
2504
2505async fn terminate_and_reap(child: &mut Box<dyn ChildWrapper>) -> Result<(), String> {
2506    let status_error = match child.try_wait() {
2507        Ok(Some(_)) => return Ok(()),
2508        Ok(None) => None,
2509        Err(error) => Some(error),
2510    };
2511    let kill_error = child.start_kill().err();
2512    let wait = tokio::time::timeout(Duration::from_secs(5), child.wait())
2513        .await
2514        .map_err(|_| "timed out waiting to reap plugin".to_owned())?
2515        .map_err(|error| format!("plugin reap failed: {error}"));
2516    if let Some(error) = status_error {
2517        return Err(format!("plugin status check failed before reap: {error}"));
2518    }
2519    if let Some(error) = kill_error {
2520        return Err(format!("plugin termination failed: {error}"));
2521    }
2522    wait.map(|_| ())
2523}
2524
2525#[cfg(test)]
2526mod producer_tests;
2527
2528#[cfg(test)]
2529mod tests;