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
178struct 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 pub(crate) fn subscribe_producer_changes(&self) -> watch::Receiver<u64> {
697 self.inner.producer_changes.subscribe()
698 }
699
700 #[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 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 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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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, ¬ifications).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 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, ¬ification).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 ¶ms.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, ¬ification).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(¶ms.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 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 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 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, ¬ification).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;