Skip to main content

agent_engine/extensions/
lease.rs

1//! Task 20 (Commit B) — session-scoped exact extension runtime leases
2//! (spec §7.5, mirroring the §7.4 MCP lease discipline).
3//!
4//! Deferred manifest-declared extension tools execute ONLY through this
5//! manager, strictly AFTER the `ExecutionGate` has authorized the exact
6//! call. One `ExtensionRuntimeManager` is shared by the `ExtensionManager`
7//! (which owns the retained internal launch records) and the `Runtime`
8//! (which mints per-session capabilities and the durable session-end
9//! scope):
10//!
11//! - a lease is keyed by (session, plugin) and PINNED to the launch-record
12//!   fingerprint (manifest + cwd + resolved config) it was acquired under;
13//!   per-key SINGLE-FLIGHT acquisition (a `Starting` placeholder holding a
14//!   `watch` receiver whose stored state makes wakeups lost-proof)
15//!   guarantees concurrent first calls never spawn duplicate children, and
16//!   the manager map lock is NEVER held across process/pipe I/O;
17//! - acquisition re-reads the retained launch record, RE-VALIDATES the
18//!   manifest (permissions included), starts exactly one child
19//!   (`kill_on_drop`), initializes ONCE, and requires the runtime's
20//!   registered declarations to match the manifest's passive declarations
21//!   EXACTLY (tool names, descriptions, canonical schema digests — and
22//!   provider declarations when present) BEFORE any call; a mismatch shuts
23//!   the child down and fails closed;
24//! - every call validates the selected exact tool name and canonical
25//!   schema digest against the pinned validated listing BEFORE `tool.call`;
26//! - revocation/termination semantics are exact and honest: revocation
27//!   marks the lease cancelled under the per-lease call permit; a call
28//!   that already passed the gate check MAY complete and then shuts the
29//!   child down; NO call passes after revocation returns. `kill_on_drop`
30//!   on the child is the hard backstop once the last `Arc` drops;
31//! - idle leases are reaped opportunistically with a CONSTANT per-pass
32//!   scan cap; session end is the drop of the LAST owner of the shared
33//!   durable [`ExtensionSessionEndGuard`] scope. No PID signalling, no
34//!   long-lived task, no unbounded channel;
35//! - lease failures carry ONLY static text, local identities, and numeric
36//!   metadata — extension-reported error content is withheld (length
37//!   only), and no launch-record (secret) config ever appears anywhere.
38
39use std::collections::HashMap;
40use std::sync::Arc;
41use std::time::{Duration, Instant};
42
43use serde_json::Value;
44use thiserror::Error;
45
46use super::context_provider::{
47    resolve_context_provider, ContextProviderId, ContextProviderLookupError,
48    RegisteredContextProviderDescriptor, SharedContextProviderCatalog,
49};
50use super::lifecycle::{
51    validate_runtime_provider_declarations, validate_runtime_tool_declarations,
52};
53use super::manager::{DeferredExtensionRecord, SharedDeferredRecords};
54use super::runtime::process::ProcessExtension;
55use super::runtime::ExtensionHandler;
56use crate::tools::activation::SessionId;
57use crate::tools::catalog::SchemaDigest;
58
59/// Default idle bound for opportunistic reaping (parity with MCP leases).
60pub const DEFAULT_IDLE_MAX: Duration = Duration::from_secs(300);
61/// Hard cap on simultaneously live leases per manager.
62pub const MAX_LIVE_LEASES: usize = 64;
63/// Reap passes scan up to the full capped map so idle entries can never
64/// starve behind an active HashMap iteration prefix.
65pub const REAP_SCAN_MAX: usize = MAX_LIVE_LEASES;
66/// Outer total bound on one lease cleanup task.
67const CLEANUP_TOTAL_TIMEOUT: Duration = Duration::from_secs(5);
68/// Bound on locally-generated transport detail strings surfaced in errors.
69const TRANSPORT_DETAIL_MAX: usize = 200;
70/// Stable prefix `ProcessExtension` puts on extension-REPORTED JSON-RPC
71/// error messages; content behind it is extension-controlled and withheld.
72const EXTENSION_REPORTED_PREFIX: &str = "Extension error:";
73
74/// Typed lease failures. Static text, local identities, and numeric
75/// metadata only — never extension-controlled content, never config.
76#[derive(Debug, Error)]
77pub enum ExtensionLeaseError {
78    #[error("extension '{0}' has no retained deferred launch record; lease revoked")]
79    NotDeferred(String),
80    #[error("extension '{0}' manifest failed re-validation at lease acquisition; lease revoked")]
81    ManifestInvalid(String),
82    #[error(
83        "extension '{0}' launch record changed since the lease was acquired; lease invalidated"
84    )]
85    LaunchDrift(String),
86    #[error("extension '{0}' no longer declares tool '{1}' with the pinned schema; lease revoked")]
87    DeclarationDrift(String, String),
88    #[error("extension '{0}' runtime declarations do not match the manifest ({1}); child terminated without any call")]
89    DeclarationMismatch(String, &'static str),
90    #[error("extension '{0}' did not register expected tool '{1}'; poisoned lease terminated without calling")]
91    NameNotListed(String, String),
92    #[error("extension '{0}' registered a schema for tool '{1}' that does not match the pinned digest; poisoned lease terminated without calling")]
93    SchemaMismatch(String, String),
94    #[error("extension lease for '{0}' was revoked before the call could start")]
95    Revoked(String),
96    #[error("extension lease capacity of {0} live leases reached; try again after idle leases are reaped")]
97    Capacity(usize),
98    #[error("extension '{0}' transport failure: {1}")]
99    Transport(String, String),
100    #[error("extension '{plugin}' reported a tool error ({length} bytes, content withheld)")]
101    ExtensionReported { plugin: String, length: usize },
102}
103
104impl ExtensionLeaseError {
105    /// Whether this failure class poisons the pinned declaration/grant
106    /// (spec §7.5): record removal, permission/config/catalog drift, and
107    /// runtime declaration mismatches invalidate the EXACT session grant.
108    /// Transport/capacity/revocation-race and extension-reported tool
109    /// errors are transient and must NOT revoke.
110    pub fn revokes_exact_grant(&self) -> bool {
111        matches!(
112            self,
113            Self::NotDeferred(_)
114                | Self::ManifestInvalid(_)
115                | Self::LaunchDrift(_)
116                | Self::DeclarationDrift(_, _)
117                | Self::DeclarationMismatch(_, _)
118                | Self::NameNotListed(_, _)
119                | Self::SchemaMismatch(_, _)
120        )
121    }
122}
123
124/// Truncate a locally-generated detail string to a hard byte bound (valid
125/// UTF-8 preserved). Extension-REPORTED content never reaches this — it is
126/// withheld entirely by the caller.
127fn bound_detail(detail: &str) -> String {
128    if detail.len() <= TRANSPORT_DETAIL_MAX {
129        return detail.to_string();
130    }
131    let mut cut = TRANSPORT_DETAIL_MAX;
132    while cut > 0 && !detail.is_char_boundary(cut) {
133        cut -= 1;
134    }
135    format!("{}… ({} bytes total)", &detail[..cut], detail.len())
136}
137
138/// Canonical fingerprint of a retained launch record: manifest identity,
139/// process cwd, and RESOLVED config. Computed locally; never logged.
140fn record_fingerprint(record: &DeferredExtensionRecord) -> SchemaDigest {
141    SchemaDigest::of_schema(&serde_json::json!({
142        "manifest": serde_json::to_value(&record.manifest).unwrap_or(Value::Null),
143        "cwd": record.cwd.as_ref().map(|p| p.display().to_string()),
144        "config": record.config,
145    }))
146}
147
148struct LeaseInner {
149    fingerprint: SchemaDigest,
150    handler: Arc<ProcessExtension>,
151    /// Per-lease call/revocation permit: the cancellation gate is read
152    /// while HOLDING this, so no new call passes after `cancel` returns.
153    call_permit: tokio::sync::Mutex<()>,
154    /// Pinned validated listing from the single initialize (tool name →
155    /// canonical schema digest).
156    listing: HashMap<String, SchemaDigest>,
157    /// `true` = cancelled. Held only for flag reads/writes — never I/O.
158    gate: std::sync::Mutex<bool>,
159    last_used: std::sync::Mutex<Instant>,
160    /// Test-observable ownership token.
161    liveness: Arc<()>,
162}
163
164impl LeaseInner {
165    fn cancelled(&self) -> bool {
166        *self
167            .gate
168            .lock()
169            .unwrap_or_else(std::sync::PoisonError::into_inner)
170    }
171
172    fn set_cancelled(&self) {
173        *self
174            .gate
175            .lock()
176            .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
177    }
178
179    fn interrupt(&self) {
180        self.set_cancelled();
181
182        // An idle lease can take the normal bounded cleanup path and send a
183        // graceful shutdown frame. Only an in-flight call needs the hard
184        // interrupt to drain its pending responder; otherwise racing an
185        // unconditional force shutdown against graceful cleanup removes the
186        // child first and used to make shutdown's restart-capable call spawn
187        // a replacement process during teardown.
188        if self.call_permit.try_lock().is_err() {
189            let handler = Arc::clone(&self.handler);
190            if let Ok(handle) = tokio::runtime::Handle::try_current() {
191                handle.spawn(async move {
192                    handler.force_shutdown().await;
193                });
194            }
195        }
196    }
197}
198
199enum LeaseState {
200    /// Single-flight placeholder; the `watch` channel stores completion
201    /// state so followers can never miss the wakeup.
202    Starting {
203        token: u64,
204        done: tokio::sync::watch::Receiver<bool>,
205    },
206    Ready(Arc<LeaseInner>),
207}
208
209/// Session-scoped exact extension runtime lease manager. See module docs.
210pub struct ExtensionRuntimeManager {
211    leases: std::sync::Mutex<HashMap<(String, String), LeaseState>>,
212    /// The `ExtensionManager`'s retained internal launch records (shared
213    /// handle; sync lock held only for map operations, never across I/O).
214    records: SharedDeferredRecords,
215    /// The `ExtensionManager`'s published context-provider catalog
216    /// snapshot (task A6; same sharing discipline as `records`). Read by
217    /// [`Self::resolve_context_provider`] for enable-time memory-context
218    /// provider validation — a PURE catalog read that never spawns.
219    context_catalog: SharedContextProviderCatalog,
220    idle_max: Duration,
221    next_token: std::sync::atomic::AtomicU64,
222    /// Host-scope session identity for HANDLER-based acquisition (hook
223    /// events, provider routing, user sidecar/command APIs). Bound at
224    /// engine boot to the Runtime's durable tool session, so a MIXED
225    /// extension's tool leases and handler leases share ONE key — one
226    /// shared child process per plugin. Unbound (tests/manual) falls back
227    /// to a fixed private scope.
228    host_scope: std::sync::OnceLock<SessionId>,
229}
230
231impl ExtensionRuntimeManager {
232    pub(crate) fn new(
233        records: SharedDeferredRecords,
234        context_catalog: SharedContextProviderCatalog,
235        idle_max: Duration,
236    ) -> Self {
237        Self {
238            leases: std::sync::Mutex::new(HashMap::new()),
239            records,
240            context_catalog,
241            idle_max,
242            next_token: std::sync::atomic::AtomicU64::new(1),
243            host_scope: std::sync::OnceLock::new(),
244        }
245    }
246
247    /// Task A6: resolve one memory-context provider request against the
248    /// published catalog of loaded extensions' declared context providers.
249    /// PURE CATALOG READ — never spawns a process, never touches lease
250    /// state (consistent with "discovery/status spawn zero processes").
251    /// Exact-scope, fail-closed matching per
252    /// [`resolve_context_provider`]: `Ok` only when exactly one loaded
253    /// declaration matches; zero matches fail `NotRegistered` and
254    /// overlapping declarations fail `Ambiguous`. Returns an owned clone
255    /// of the dormant descriptor (bounded metadata only).
256    pub fn resolve_context_provider(
257        &self,
258        requested: Option<&ContextProviderId>,
259    ) -> Result<RegisteredContextProviderDescriptor, ContextProviderLookupError> {
260        let catalog = self
261            .context_catalog
262            .lock()
263            .unwrap_or_else(std::sync::PoisonError::into_inner);
264        resolve_context_provider(&catalog, requested).cloned()
265    }
266
267    /// Bind the host-scope session identity used by handler-based
268    /// acquisition (first binder wins; the engine binds the Runtime's
269    /// durable tool session at boot so Mixed extensions share one child).
270    pub fn bind_host_scope(&self, session: SessionId) {
271        let _ = self.host_scope.set(session);
272    }
273
274    /// The host-scope session identity (bound, or the private fallback).
275    pub fn host_scope(&self) -> SessionId {
276        self.host_scope
277            .get_or_init(|| {
278                SessionId::parse("extension-host-scope")
279                    .expect("static host scope identity is valid")
280            })
281            .clone()
282    }
283
284    fn current_record(&self, plugin: &str) -> Option<DeferredExtensionRecord> {
285        self.records
286            .lock()
287            .unwrap_or_else(std::sync::PoisonError::into_inner)
288            .get(plugin)
289            .cloned()
290    }
291
292    /// Task B4: pinned schema digest of one plugin's CURRENTLY declared
293    /// deferred tool — a pure launch-record read that never spawns. The
294    /// memory recall dispatch uses it to address the provider's declared
295    /// recall tool through the same exact-digest [`Self::call_exact`] gate
296    /// as every other extension call (drift still re-checked there).
297    pub(crate) fn declared_tool_digest(
298        &self,
299        plugin: &str,
300        tool_name: &str,
301    ) -> Option<SchemaDigest> {
302        let record = self.current_record(plugin)?;
303        record
304            .manifest
305            .deferred
306            .as_ref()?
307            .tools
308            .iter()
309            .find(|tool| tool.name == tool_name)
310            .map(|tool| SchemaDigest::of_schema(&tool.input_schema))
311    }
312
313    /// Mark cancelled and hand the lease to ONE bounded cleanup task
314    /// (graceful `shutdown` RPC + kill under an outer timeout). Without a
315    /// runtime handle the last `Arc` drop triggers `kill_on_drop` — the
316    /// hard backstop in every path.
317    fn cancel_state(state: LeaseState) {
318        match state {
319            LeaseState::Ready(inner) => {
320                inner.interrupt();
321                Self::spawn_bounded_cleanup(inner);
322            }
323            // The in-flight starter detects its placeholder is gone and
324            // shuts its own child down instead of publishing the lease.
325            LeaseState::Starting { .. } => {}
326        }
327    }
328
329    fn spawn_bounded_cleanup(inner: Arc<LeaseInner>) {
330        if let Ok(handle) = tokio::runtime::Handle::try_current() {
331            handle.spawn(async move {
332                let _ = tokio::time::timeout(CLEANUP_TOTAL_TIMEOUT, async {
333                    // Wait for any in-flight call to release the permit so
334                    // shutdown never races an active tool.call frame.
335                    let _permit = inner.call_permit.lock().await;
336                    inner.handler.shutdown().await;
337                })
338                .await;
339            });
340        }
341        // No runtime: dropping the Arc (here or when bailing callers
342        // release theirs) kills the child via kill_on_drop.
343    }
344
345    /// Number of live (Ready or Starting) leases.
346    pub fn lease_count(&self) -> usize {
347        self.leases
348            .lock()
349            .unwrap_or_else(std::sync::PoisonError::into_inner)
350            .len()
351    }
352
353    /// Terminate one session's runtime lease for one PLUGIN (the lease
354    /// unit: one child shared by that session's exact tools of the plugin).
355    pub fn revoke_plugin_lease(&self, session: &SessionId, plugin: &str) {
356        let removed = {
357            let mut map = self
358                .leases
359                .lock()
360                .unwrap_or_else(std::sync::PoisonError::into_inner);
361            map.remove(&(session.as_str().to_string(), plugin.to_string()))
362        };
363        if let Some(state) = removed {
364            Self::cancel_state(state);
365        }
366    }
367
368    /// Revoke one EXACT tool grant's runtime backing. Grant revocation
369    /// itself is gate-level; at the runtime layer the lease unit is the
370    /// plugin child, so this conservatively terminates the shared lease.
371    pub fn revoke_exact_tool(&self, session: &SessionId, plugin: &str, tool_name: &str) {
372        let _ = tool_name; // exactness lives in the gate; lease unit is the plugin child
373        self.revoke_plugin_lease(session, plugin);
374    }
375
376    /// Terminate every session's lease for one plugin (manager unload/
377    /// reload path). Returns how many were terminated.
378    pub fn revoke_plugin_all_sessions(&self, plugin: &str) -> usize {
379        let removed: Vec<LeaseState> = {
380            let mut map = self
381                .leases
382                .lock()
383                .unwrap_or_else(std::sync::PoisonError::into_inner);
384            let keys: Vec<(String, String)> =
385                map.keys().filter(|(_, p)| p == plugin).cloned().collect();
386            keys.into_iter().filter_map(|k| map.remove(&k)).collect()
387        };
388        let count = removed.len();
389        for state in removed {
390            Self::cancel_state(state);
391        }
392        count
393    }
394
395    /// Terminate every lease held by one session. Returns how many.
396    pub fn terminate_session(&self, session: &SessionId) -> usize {
397        let removed: Vec<LeaseState> = {
398            let mut map = self
399                .leases
400                .lock()
401                .unwrap_or_else(std::sync::PoisonError::into_inner);
402            let keys: Vec<(String, String)> = map
403                .keys()
404                .filter(|(s, _)| s == session.as_str())
405                .cloned()
406                .collect();
407            keys.into_iter().filter_map(|k| map.remove(&k)).collect()
408        };
409        let count = removed.len();
410        for state in removed {
411            Self::cancel_state(state);
412        }
413        count
414    }
415
416    /// Terminate every lease (runtime shutdown).
417    pub fn terminate_all(&self) {
418        let removed: Vec<LeaseState> = {
419            let mut map = self
420                .leases
421                .lock()
422                .unwrap_or_else(std::sync::PoisonError::into_inner);
423            map.drain().map(|(_, v)| v).collect()
424        };
425        for state in removed {
426            Self::cancel_state(state);
427        }
428    }
429
430    /// Reap Ready leases idle past the configured bound. Work per pass is
431    /// bounded by [`REAP_SCAN_MAX`] examined keys.
432    pub fn reap_idle(&self) {
433        let removed: Vec<LeaseState> = {
434            let mut map = self
435                .leases
436                .lock()
437                .unwrap_or_else(std::sync::PoisonError::into_inner);
438            let idle: Vec<(String, String)> = map
439                .iter()
440                .take(REAP_SCAN_MAX)
441                .filter(|(_, state)| match state {
442                    LeaseState::Ready(inner) => {
443                        inner
444                            .last_used
445                            .lock()
446                            .unwrap_or_else(std::sync::PoisonError::into_inner)
447                            .elapsed()
448                            >= self.idle_max
449                    }
450                    LeaseState::Starting { .. } => false,
451                })
452                .map(|(k, _)| k.clone())
453                .collect();
454            idle.into_iter().filter_map(|k| map.remove(&k)).collect()
455        };
456        for state in removed {
457            Self::cancel_state(state);
458        }
459    }
460
461    /// Single-flight acquisition of the validated Ready lease for one
462    /// (session, plugin) key. Shared by exact tool calls and handler-based
463    /// (hook/provider/user) acquisition — one child per key. The map lock
464    /// is never held across I/O; a live lease whose pinned launch
465    /// fingerprint no longer matches the CURRENT record is launch drift
466    /// and fails closed after terminating the stale child.
467    async fn acquire_ready(
468        &self,
469        session: &SessionId,
470        plugin: &str,
471        record: &DeferredExtensionRecord,
472        validated: &super::manifest::ValidatedExtensionManifest,
473    ) -> Result<Arc<LeaseInner>, ExtensionLeaseError> {
474        let expected_fingerprint = record_fingerprint(record);
475        let key = (session.as_str().to_string(), plugin.to_string());
476        let inner: Arc<LeaseInner> = loop {
477            // 4. Single-flight acquisition: the guard-scoped decision
478            // returns an owned action, so the map lock is NEVER held
479            // across I/O or awaits.
480            enum Acquire {
481                Use(Arc<LeaseInner>),
482                CancelStale(LeaseState),
483                Wait(tokio::sync::watch::Receiver<bool>),
484                Start {
485                    token: u64,
486                    done_tx: tokio::sync::watch::Sender<bool>,
487                },
488                AtCapacity,
489            }
490            let action = {
491                let mut map = self
492                    .leases
493                    .lock()
494                    .unwrap_or_else(std::sync::PoisonError::into_inner);
495                match map.get(&key) {
496                    Some(LeaseState::Ready(inner)) => {
497                        if inner.cancelled() || inner.fingerprint != expected_fingerprint {
498                            match map.remove(&key) {
499                                Some(state) => Acquire::CancelStale(state),
500                                None => continue,
501                            }
502                        } else {
503                            Acquire::Use(Arc::clone(inner))
504                        }
505                    }
506                    Some(LeaseState::Starting { done, .. }) => Acquire::Wait(done.clone()),
507                    None => {
508                        if map.len() >= MAX_LIVE_LEASES {
509                            Acquire::AtCapacity
510                        } else {
511                            let token = self
512                                .next_token
513                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
514                            let (done_tx, done_rx) = tokio::sync::watch::channel(false);
515                            map.insert(
516                                key.clone(),
517                                LeaseState::Starting {
518                                    token,
519                                    done: done_rx,
520                                },
521                            );
522                            Acquire::Start { token, done_tx }
523                        }
524                    }
525                }
526            };
527            match action {
528                Acquire::Use(inner) => break inner,
529                Acquire::CancelStale(state) => {
530                    // A live lease whose pinned fingerprint no longer
531                    // matches the current record is launch drift: the
532                    // pinned grant falls with it (spec §7.5).
533                    let was_drift = matches!(
534                        &state,
535                        LeaseState::Ready(inner) if inner.fingerprint != expected_fingerprint
536                            && !inner.cancelled()
537                    );
538                    Self::cancel_state(state);
539                    if was_drift {
540                        return Err(ExtensionLeaseError::LaunchDrift(plugin.to_string()));
541                    }
542                    continue;
543                }
544                Acquire::AtCapacity => {
545                    self.reap_idle();
546                    let still_full = self
547                        .leases
548                        .lock()
549                        .unwrap_or_else(std::sync::PoisonError::into_inner)
550                        .len()
551                        >= MAX_LIVE_LEASES;
552                    if still_full {
553                        return Err(ExtensionLeaseError::Capacity(MAX_LIVE_LEASES));
554                    }
555                    continue;
556                }
557                Acquire::Wait(mut follower) => {
558                    // Lost-wakeup-proof: the watch channel stores state.
559                    if !*follower.borrow() {
560                        let _ = follower.changed().await;
561                    }
562                    continue;
563                }
564                Acquire::Start { token, done_tx } => {
565                    let started = self
566                        .start_lease(plugin, record, validated, expected_fingerprint.clone())
567                        .await;
568                    let ours = {
569                        let mut map = self
570                            .leases
571                            .lock()
572                            .unwrap_or_else(std::sync::PoisonError::into_inner);
573                        let ours = matches!(
574                            map.get(&key),
575                            Some(LeaseState::Starting { token: t, .. }) if *t == token
576                        );
577                        match &started {
578                            Ok(inner) if ours => {
579                                map.insert(key.clone(), LeaseState::Ready(Arc::clone(inner)));
580                            }
581                            Err(_) if ours => {
582                                map.remove(&key);
583                            }
584                            _ => {}
585                        }
586                        ours
587                    };
588                    match started {
589                        Ok(inner) => {
590                            if !ours {
591                                // Terminated while starting (session end or
592                                // revocation): never resurrect.
593                                inner.set_cancelled();
594                                inner.handler.shutdown().await;
595                                let _ = done_tx.send(true);
596                                return Err(ExtensionLeaseError::Revoked(plugin.to_string()));
597                            }
598                            let _ = done_tx.send(true);
599                            break inner;
600                        }
601                        Err(err) => {
602                            let _ = done_tx.send(true);
603                            return Err(err);
604                        }
605                    }
606                }
607            }
608        };
609
610        Ok(inner)
611    }
612
613    /// Acquire the shared validated per-plugin lease under the HOST scope
614    /// for handler-based capabilities (hook events, provider routing, user
615    /// sidecar/command/settings APIs). Same record re-validation, exact
616    /// runtime declaration matching, single-flight, and teardown rules as
617    /// exact tool calls — and, when the host scope is bound to the
618    /// Runtime's tool session (engine boot), the SAME lease key: a Mixed
619    /// extension runs ONE shared child.
620    async fn acquire_handler_scope(
621        &self,
622        plugin: &str,
623    ) -> Result<Arc<LeaseInner>, ExtensionLeaseError> {
624        let session = self.host_scope();
625        let (record, validated) = self.record_and_validate(&session, plugin)?;
626        self.reap_idle();
627        self.acquire_ready(&session, plugin, &record, &validated)
628            .await
629    }
630
631    /// Whether a Ready (non-cancelled) lease exists for one key.
632    fn has_ready_lease(&self, session: &SessionId, plugin: &str) -> bool {
633        let map = self
634            .leases
635            .lock()
636            .unwrap_or_else(std::sync::PoisonError::into_inner);
637        matches!(
638            map.get(&(session.as_str().to_string(), plugin.to_string())),
639            Some(LeaseState::Ready(inner)) if !inner.cancelled()
640        )
641    }
642
643    /// Steps shared by every acquisition path: re-read the retained
644    /// internal launch record and RE-VALIDATE the manifest (permissions
645    /// included) before any spawn decision. Failure revokes the session's
646    /// plugin lease and fails closed.
647    fn record_and_validate(
648        &self,
649        session: &SessionId,
650        plugin: &str,
651    ) -> Result<
652        (
653            DeferredExtensionRecord,
654            super::manifest::ValidatedExtensionManifest,
655        ),
656        ExtensionLeaseError,
657    > {
658        let Some(record) = self.current_record(plugin) else {
659            self.revoke_plugin_lease(session, plugin);
660            return Err(ExtensionLeaseError::NotDeferred(plugin.to_string()));
661        };
662        let Ok(validated) = record.manifest.validate(plugin) else {
663            self.revoke_plugin_lease(session, plugin);
664            return Err(ExtensionLeaseError::ManifestInvalid(plugin.to_string()));
665        };
666        Ok((record, validated))
667    }
668
669    /// Execute one EXACT already-gate-authorized extension tool call. See
670    /// module docs for the lease/single-flight/validation contract.
671    /// Host-originated: the wire `session_id` is `null`.
672    pub async fn call_exact(
673        &self,
674        session: &SessionId,
675        plugin: &str,
676        tool_name: &str,
677        expected_digest: &SchemaDigest,
678        params: Value,
679    ) -> Result<Value, ExtensionLeaseError> {
680        self.call_exact_for(session, plugin, tool_name, expected_digest, params, None)
681            .await
682    }
683
684    /// [`Self::call_exact`] on behalf of a conversation: `conversation_id`
685    /// is forwarded as `tool.call` `params.session_id` (the owning
686    /// conversation, not the lease scope `session`).
687    pub async fn call_exact_for(
688        &self,
689        session: &SessionId,
690        plugin: &str,
691        tool_name: &str,
692        expected_digest: &SchemaDigest,
693        params: Value,
694        conversation_id: Option<&str>,
695    ) -> Result<Value, ExtensionLeaseError> {
696        // 1.-2. Record re-read + manifest re-validation (pure local).
697        let (record, validated) = self.record_and_validate(session, plugin)?;
698        // 3. Catalog-drift check: the CURRENT record must still declare
699        // this exact tool with the digest pinned in the catalog entry.
700        let declared_tools = record
701            .manifest
702            .deferred
703            .as_ref()
704            .map(|d| d.tools.as_slice())
705            .unwrap_or(&[]);
706        let still_declared = declared_tools.iter().any(|t| {
707            t.name == tool_name && &SchemaDigest::of_schema(&t.input_schema) == expected_digest
708        });
709        if !still_declared {
710            self.revoke_plugin_lease(session, plugin);
711            return Err(ExtensionLeaseError::DeclarationDrift(
712                plugin.to_string(),
713                tool_name.to_string(),
714            ));
715        }
716        self.reap_idle();
717
718        // 4. Single-flight acquisition of the validated per-plugin lease.
719        let inner = self
720            .acquire_ready(session, plugin, &record, &validated)
721            .await?;
722        // 5. Exact validation against the pinned validated listing BEFORE
723        // any call.
724        match inner.listing.get(tool_name) {
725            None => {
726                self.revoke_plugin_lease(session, plugin);
727                return Err(ExtensionLeaseError::NameNotListed(
728                    plugin.to_string(),
729                    tool_name.to_string(),
730                ));
731            }
732            Some(listed) if listed != expected_digest => {
733                self.revoke_plugin_lease(session, plugin);
734                return Err(ExtensionLeaseError::SchemaMismatch(
735                    plugin.to_string(),
736                    tool_name.to_string(),
737                ));
738            }
739            Some(_) => {}
740        }
741
742        // 6. Call under the per-lease permit; the revocation gate check is
743        // taken while HOLDING it, so after cancellation no new call passes.
744        let permit = inner.call_permit.lock().await;
745        if inner.cancelled() {
746            return Err(ExtensionLeaseError::Revoked(plugin.to_string()));
747        }
748        let result = inner
749            .handler
750            .call_tool_once_no_spawn(tool_name, params, conversation_id)
751            .await;
752        if inner.cancelled() {
753            // Revoked while this already-started call ran: shut the child
754            // down before releasing the permit.
755            inner.handler.shutdown().await;
756        }
757        drop(permit);
758        *inner
759            .last_used
760            .lock()
761            .unwrap_or_else(std::sync::PoisonError::into_inner) = Instant::now();
762        result.map_err(|e| {
763            if let Some(rest) = e.strip_prefix(EXTENSION_REPORTED_PREFIX) {
764                // Extension-controlled content: withheld, length only.
765                ExtensionLeaseError::ExtensionReported {
766                    plugin: plugin.to_string(),
767                    length: rest.trim().len(),
768                }
769            } else {
770                ExtensionLeaseError::Transport(plugin.to_string(), bound_detail(&e))
771            }
772        })
773    }
774
775    /// Spawn + set permissions + initialize ONCE for one plugin, then
776    /// require the runtime's declarations to match the manifest EXACTLY.
777    /// No manager lock is held. Any failure shuts the child down before
778    /// returning (`kill_on_drop` backs the local paths).
779    async fn start_lease(
780        &self,
781        plugin: &str,
782        record: &DeferredExtensionRecord,
783        validated: &super::manifest::ValidatedExtensionManifest,
784        fingerprint: SchemaDigest,
785    ) -> Result<Arc<LeaseInner>, ExtensionLeaseError> {
786        let manifest = &record.manifest;
787        let process = ProcessExtension::spawn_with_memory_policy(
788            plugin,
789            &manifest.command,
790            &manifest.args,
791            record.cwd.clone(),
792            record.exclusive_memory,
793        )
794        .await
795        .map_err(|e| ExtensionLeaseError::Transport(plugin.to_string(), bound_detail(&e)))?;
796        process.set_permissions(validated.permissions.clone()).await;
797        let caps = match process
798            .initialize(record.cwd.clone(), record.config.clone())
799            .await
800        {
801            Ok(caps) => caps,
802            Err(e) => {
803                process.shutdown().await;
804                let err = if let Some(rest) = e.strip_prefix(EXTENSION_REPORTED_PREFIX) {
805                    ExtensionLeaseError::ExtensionReported {
806                        plugin: plugin.to_string(),
807                        length: rest.trim().len(),
808                    }
809                } else {
810                    ExtensionLeaseError::Transport(plugin.to_string(), bound_detail(&e))
811                };
812                return Err(err);
813            }
814        };
815        let deferred = manifest.deferred.as_ref();
816        let declared_tools = deferred.map(|d| d.tools.as_slice()).unwrap_or(&[]);
817        let declared_providers = deferred.map(|d| d.providers.as_slice()).unwrap_or(&[]);
818        if let Err(reason) = validate_runtime_tool_declarations(declared_tools, &caps.tools) {
819            process.shutdown().await;
820            return Err(ExtensionLeaseError::DeclarationMismatch(
821                plugin.to_string(),
822                reason,
823            ));
824        }
825        if let Err(reason) =
826            validate_runtime_provider_declarations(declared_providers, &caps.providers)
827        {
828            process.shutdown().await;
829            return Err(ExtensionLeaseError::DeclarationMismatch(
830                plugin.to_string(),
831                reason,
832            ));
833        }
834        for decl in &caps.capabilities {
835            if super::runtime::process::validate_capability(decl, &validated.permissions).is_err() {
836                process.shutdown().await;
837                return Err(ExtensionLeaseError::DeclarationMismatch(
838                    plugin.to_string(),
839                    "capability_declaration_rejected",
840                ));
841            }
842        }
843        let mut listing = HashMap::new();
844        for spec in &caps.tools {
845            listing.insert(
846                spec.name.clone(),
847                SchemaDigest::of_schema(&spec.input_schema),
848            );
849        }
850        Ok(Arc::new(LeaseInner {
851            fingerprint,
852            handler: Arc::new(process),
853            call_permit: tokio::sync::Mutex::new(()),
854            listing,
855            gate: std::sync::Mutex::new(false),
856            last_used: std::sync::Mutex::new(Instant::now()),
857            liveness: Arc::new(()),
858        }))
859    }
860
861    /// Test seam: `Weak` ownership token of one lease. `upgrade() == None`
862    /// proves the manager (and any bounded shutdown task) released it.
863    #[doc(hidden)]
864    pub fn lease_liveness_for_tests(
865        &self,
866        session: &SessionId,
867        plugin: &str,
868    ) -> Option<std::sync::Weak<()>> {
869        let map = self
870            .leases
871            .lock()
872            .unwrap_or_else(std::sync::PoisonError::into_inner);
873        match map.get(&(session.as_str().to_string(), plugin.to_string())) {
874            Some(LeaseState::Ready(inner)) => Some(Arc::downgrade(&inner.liveness)),
875            _ => None,
876        }
877    }
878}
879
880impl Drop for ExtensionRuntimeManager {
881    fn drop(&mut self) {
882        self.terminate_all();
883    }
884}
885
886impl std::fmt::Debug for ExtensionRuntimeManager {
887    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
888        f.debug_struct("ExtensionRuntimeManager")
889            .field("leases", &self.lease_count())
890            .finish_non_exhaustive()
891    }
892}
893
894/// Typed per-session lease capability handed to tool contexts: the exact
895/// session identity plus the shared manager. Never widens to a plugin
896/// grant — every call re-validates the exact tool and launch record.
897#[derive(Clone)]
898pub struct ExtensionLeaseCapability {
899    session: SessionId,
900    manager: Arc<ExtensionRuntimeManager>,
901}
902
903impl ExtensionLeaseCapability {
904    pub fn new(session: SessionId, manager: Arc<ExtensionRuntimeManager>) -> Self {
905        Self { session, manager }
906    }
907
908    /// The exact session identity this capability is scoped to.
909    pub fn session(&self) -> &SessionId {
910        &self.session
911    }
912
913    pub async fn call_exact(
914        &self,
915        plugin: &str,
916        tool_name: &str,
917        expected_digest: &SchemaDigest,
918        params: Value,
919    ) -> Result<Value, ExtensionLeaseError> {
920        self.manager
921            .call_exact(&self.session, plugin, tool_name, expected_digest, params)
922            .await
923    }
924
925    /// [`Self::call_exact`] on behalf of a conversation; see
926    /// [`ExtensionRuntimeManager::call_exact_for`].
927    pub async fn call_exact_for(
928        &self,
929        plugin: &str,
930        tool_name: &str,
931        expected_digest: &SchemaDigest,
932        params: Value,
933        conversation_id: Option<&str>,
934    ) -> Result<Value, ExtensionLeaseError> {
935        self.manager
936            .call_exact_for(
937                &self.session,
938                plugin,
939                tool_name,
940                expected_digest,
941                params,
942                conversation_id,
943            )
944            .await
945    }
946}
947
948impl std::fmt::Debug for ExtensionLeaseCapability {
949    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
950        f.debug_struct("ExtensionLeaseCapability")
951            .finish_non_exhaustive()
952    }
953}
954
955/// Durable last-owner session scope. The Runtime mints ONE of these per
956/// tool session (wrapped in an `Arc` shared by every runtime clone and
957/// in-flight stream); streams HOLD it, never construct or drop their own.
958/// Leases therefore survive across provider turns, and only the drop of
959/// the LAST owner — true session end — terminates the session's leases.
960pub struct ExtensionSessionEndGuard {
961    session: SessionId,
962    manager: Arc<ExtensionRuntimeManager>,
963}
964
965impl ExtensionSessionEndGuard {
966    pub fn new(session: SessionId, manager: Arc<ExtensionRuntimeManager>) -> Self {
967        Self { session, manager }
968    }
969}
970
971impl Drop for ExtensionSessionEndGuard {
972    fn drop(&mut self) {
973        self.manager.terminate_session(&self.session);
974    }
975}
976
977impl std::fmt::Debug for ExtensionSessionEndGuard {
978    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
979        f.debug_struct("ExtensionSessionEndGuard")
980            .finish_non_exhaustive()
981    }
982}
983
984/// Lazy [`ExtensionHandler`] for deferred hook/provider/user capabilities
985/// (Task 20 Commit C). Registered/subscribed at load WITHOUT a process;
986/// the first AUTHORIZED use (a matching hook event delivered by the
987/// permission-checked `HookBus` subscription, a selected provider
988/// complete/stream, or an explicit user sidecar/command/settings action)
989/// single-flights through the SAME per-plugin lease manager — starting
990/// and exact-validating the child once. Discovery, load, search, and
991/// diagnostics never acquire.
992pub struct LazyExtensionHandler {
993    plugin: String,
994    manager: Arc<ExtensionRuntimeManager>,
995}
996
997impl LazyExtensionHandler {
998    pub fn new(plugin: &str, manager: Arc<ExtensionRuntimeManager>) -> Self {
999        Self {
1000            plugin: plugin.to_string(),
1001            manager,
1002        }
1003    }
1004
1005    /// Acquire the shared validated lease (host scope) and hand back the
1006    /// live inner. Static-only failure text.
1007    async fn live(&self) -> Result<Arc<LeaseInner>, String> {
1008        self.manager
1009            .acquire_handler_scope(&self.plugin)
1010            .await
1011            .map_err(|e| e.to_string())
1012    }
1013
1014    fn touch(inner: &LeaseInner) {
1015        *inner
1016            .last_used
1017            .lock()
1018            .unwrap_or_else(std::sync::PoisonError::into_inner) = Instant::now();
1019    }
1020}
1021
1022#[async_trait::async_trait]
1023impl ExtensionHandler for LazyExtensionHandler {
1024    fn id(&self) -> &str {
1025        &self.plugin
1026    }
1027
1028    async fn handle(
1029        &self,
1030        event: &crate::extensions::hooks::events::HookEvent,
1031    ) -> crate::extensions::hooks::events::HookResult {
1032        // First AUTHORIZED matching event (the HookBus checked the
1033        // permission at subscribe time and matched the filter) starts the
1034        // child. Acquisition failure follows the documented eager hook
1035        // transport policy: warn + Continue (never a silent block).
1036        match self.live().await {
1037            Ok(inner) => {
1038                let result = inner.handler.handle(event).await;
1039                Self::touch(&inner);
1040                result
1041            }
1042            Err(error) => {
1043                tracing::warn!(
1044                    extension = %self.plugin,
1045                    error = %error,
1046                    "Deferred extension could not start for hook event — continuing",
1047                );
1048                crate::extensions::hooks::events::HookResult::Continue
1049            }
1050        }
1051    }
1052
1053    async fn call_tool(&self, _name: &str, _input: Value) -> Result<Value, String> {
1054        // FAIL CLOSED: deferred extension tool execution flows ONLY
1055        // through the gate-authorized exact lease path
1056        // (`ExtensionRuntimeManager::call_exact`, which pins and checks
1057        // the declared name + canonical schema digest). A generic handler
1058        // call with an arbitrary tool name would bypass those checks.
1059        Err(format!(
1060            "extension '{}' is deferred: tool calls are only served through the \
1061             gate-authorized exact activation path",
1062            self.plugin
1063        ))
1064    }
1065
1066    async fn provider_complete(
1067        &self,
1068        params: super::runtime::process::ProviderCompleteParams,
1069    ) -> Result<super::runtime::process::ProviderCompleteResult, String> {
1070        let inner = self.live().await?;
1071        let result = inner.handler.provider_complete(params).await;
1072        Self::touch(&inner);
1073        result
1074    }
1075
1076    async fn provider_stream(
1077        &self,
1078        params: super::runtime::process::ProviderCompleteParams,
1079        sink: tokio::sync::mpsc::Sender<super::runtime::process::ProviderStreamEvent>,
1080    ) -> Result<super::runtime::process::ProviderCompleteResult, String> {
1081        let inner = self.live().await?;
1082        let result = inner.handler.provider_stream(params, sink).await;
1083        Self::touch(&inner);
1084        result
1085    }
1086
1087    async fn invoke_command(
1088        &self,
1089        command: &str,
1090        args: Vec<String>,
1091        request_id: &str,
1092        sink: crate::extensions::invoke_output::InvokeEventSink,
1093    ) -> Result<Value, String> {
1094        let inner = self.live().await?;
1095        let result = inner
1096            .handler
1097            .invoke_command(command, args, request_id, sink)
1098            .await;
1099        Self::touch(&inner);
1100        result
1101    }
1102
1103    async fn get_info(&self) -> Result<crate::extensions::info::PluginInfo, String> {
1104        // Diagnostics must never spawn a deferred extension.
1105        Err("deferred extension info is unavailable without activation".to_string())
1106    }
1107
1108    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1109        // Explicit user sidecar action: a legitimate acquisition trigger.
1110        let inner = self.live().await?;
1111        let result = inner.handler.sidecar_spawn_args().await;
1112        Self::touch(&inner);
1113        result
1114    }
1115
1116    async fn settings_editor_open(&self, category: &str, field: &str) -> Result<Value, String> {
1117        let inner = self.live().await?;
1118        let result = inner.handler.settings_editor_open(category, field).await;
1119        Self::touch(&inner);
1120        result
1121    }
1122
1123    async fn settings_editor_key(
1124        &self,
1125        category: &str,
1126        field: &str,
1127        key: &str,
1128    ) -> Result<Value, String> {
1129        let inner = self.live().await?;
1130        let result = inner
1131            .handler
1132            .settings_editor_key(category, field, key)
1133            .await;
1134        Self::touch(&inner);
1135        result
1136    }
1137
1138    async fn settings_editor_commit(
1139        &self,
1140        category: &str,
1141        field: &str,
1142        value: Value,
1143    ) -> Result<Value, String> {
1144        let inner = self.live().await?;
1145        let result = inner
1146            .handler
1147            .settings_editor_commit(category, field, value)
1148            .await;
1149        Self::touch(&inner);
1150        result
1151    }
1152
1153    async fn shutdown(&self) {
1154        // Terminates every session's lease for this plugin; never spawns.
1155        self.manager.revoke_plugin_all_sessions(&self.plugin);
1156    }
1157
1158    async fn health(&self) -> super::runtime::ExtensionHealth {
1159        // Dormant reporting only — health checks never spawn.
1160        if self
1161            .manager
1162            .has_ready_lease(&self.manager.host_scope(), &self.plugin)
1163        {
1164            super::runtime::ExtensionHealth::Running
1165        } else {
1166            super::runtime::ExtensionHealth::Loaded
1167        }
1168    }
1169}
1170
1171impl std::fmt::Debug for LazyExtensionHandler {
1172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1173        f.debug_struct("LazyExtensionHandler")
1174            .field("plugin", &self.plugin)
1175            .finish_non_exhaustive()
1176    }
1177}