Skip to main content

agent_engine/extensions/runtime/
process.rs

1//! Process-based extension runtime — JSON-RPC 2.0 over stdio.
2//!
3//! Spawns the extension as a child process. Communication uses
4//! Content-Length framing (LSP-style) over stdin/stdout.
5
6use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8use std::process::Stdio;
9use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, ChildStdout, Command};
17use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
18use tokio::task::JoinHandle;
19
20use super::{ExtensionHandler, ExtensionHealth, ExtensionLifecycle, RestartPolicy};
21use crate::extensions::hooks::events::{HookEvent, HookResult};
22use crate::extensions::manifest::CURRENT_EXTENSION_PROTOCOL_VERSION;
23
24#[derive(Serialize)]
25struct JsonRpcRequest {
26    jsonrpc: &'static str,
27    method: String,
28    params: Value,
29    id: u64,
30}
31
32#[derive(Serialize)]
33struct InitializeParams {
34    synaps_version: &'static str,
35    extension_protocol_version: u32,
36    plugin_id: String,
37    plugin_root: Option<String>,
38    config: Value,
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42pub struct RegisteredExtensionToolSpec {
43    pub name: String,
44    pub description: String,
45    pub input_schema: Value,
46}
47
48#[derive(Debug, Clone, PartialEq, Deserialize)]
49pub struct RegisteredProviderSpec {
50    pub id: String,
51    pub display_name: String,
52    pub description: String,
53    #[serde(default)]
54    pub models: Vec<RegisteredProviderModelSpec>,
55    #[serde(default)]
56    pub config_schema: Option<Value>,
57}
58
59#[derive(Debug, Clone, PartialEq, Deserialize)]
60pub struct RegisteredProviderModelSpec {
61    pub id: String,
62    #[serde(default)]
63    pub display_name: Option<String>,
64    #[serde(default)]
65    pub capabilities: Value,
66    #[serde(default)]
67    pub context_window: Option<u64>,
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct ProviderCompleteParams {
72    pub provider_id: String,
73    pub model_id: String,
74    pub model: String,
75    pub messages: Vec<crate::SharedMessage>,
76    pub system_prompt: Option<String>,
77    pub tools: Vec<Value>,
78    pub temperature: Option<f32>,
79    pub max_tokens: Option<u32>,
80    pub thinking_budget: u32,
81}
82
83#[derive(Debug, Clone, PartialEq, Deserialize)]
84pub struct ProviderCompleteResult {
85    pub content: Vec<Value>,
86    #[serde(default)]
87    pub stop_reason: Option<String>,
88    #[serde(default)]
89    pub usage: Option<Value>,
90}
91
92#[derive(Debug, Clone, PartialEq)]
93pub struct ProviderToolUse {
94    pub id: String,
95    pub name: String,
96    pub input: Value,
97}
98
99/// A single streaming event from a provider extension.
100#[derive(Debug, Clone, PartialEq)]
101pub enum ProviderStreamEvent {
102    /// Incremental assistant text.
103    TextDelta { text: String },
104    /// Incremental thinking text.
105    ThinkingDelta { text: String },
106    /// A complete tool-use block (matches ProviderToolUse fields).
107    ToolUse {
108        id: String,
109        name: String,
110        input: Value,
111    },
112    /// Usage metadata (typically near end-of-stream).
113    Usage { usage: Value },
114    /// Provider-side error (non-fatal stream notification — caller decides).
115    Error { message: String },
116    /// Optional explicit end-of-stream marker.
117    Done,
118}
119
120/// Parse a single `provider.stream.event` notification's `params` value into a
121/// [`ProviderStreamEvent`]. Returns `Err(String)` on malformed input.
122///
123/// Accepts both `{"event": {"type": "...", ...}}` and flat `{"type": "...", ...}`
124/// shapes.
125pub fn parse_provider_stream_event(params: &Value) -> Result<ProviderStreamEvent, String> {
126    let inner = match params.get("event") {
127        Some(ev) => ev,
128        None => params,
129    };
130    let obj = inner
131        .as_object()
132        .ok_or_else(|| "provider stream event must be a JSON object".to_string())?;
133
134    let ty = obj
135        .get("type")
136        .and_then(Value::as_str)
137        .ok_or_else(|| "provider stream event missing type".to_string())?;
138
139    match ty {
140        "text" => {
141            let text = obj
142                .get("delta")
143                .or_else(|| obj.get("text"))
144                .and_then(Value::as_str)
145                .ok_or_else(|| {
146                    "provider stream text event missing 'delta' or 'text'".to_string()
147                })?;
148            Ok(ProviderStreamEvent::TextDelta {
149                text: text.to_string(),
150            })
151        }
152        "thinking" => {
153            let text = obj
154                .get("delta")
155                .or_else(|| obj.get("text"))
156                .and_then(Value::as_str)
157                .ok_or_else(|| {
158                    "provider stream thinking event missing 'delta' or 'text'".to_string()
159                })?;
160            Ok(ProviderStreamEvent::ThinkingDelta {
161                text: text.to_string(),
162            })
163        }
164        "tool_use" => {
165            let id = obj
166                .get("id")
167                .and_then(Value::as_str)
168                .ok_or_else(|| "provider stream tool_use missing id".to_string())?;
169            if id.is_empty() {
170                return Err("provider stream tool_use id must be non-empty".to_string());
171            }
172            let name = obj
173                .get("name")
174                .and_then(Value::as_str)
175                .ok_or_else(|| "provider stream tool_use missing name".to_string())?;
176            if name.is_empty() {
177                return Err("provider stream tool_use name must be non-empty".to_string());
178            }
179            let input = match obj.get("input") {
180                None => Value::Object(Default::default()),
181                Some(v) if v.is_object() => v.clone(),
182                Some(_) => {
183                    return Err("provider stream tool_use input must be a JSON object".to_string());
184                }
185            };
186            Ok(ProviderStreamEvent::ToolUse {
187                id: id.to_string(),
188                name: name.to_string(),
189                input,
190            })
191        }
192        "usage" => {
193            let mut clone = obj.clone();
194            clone.remove("type");
195            Ok(ProviderStreamEvent::Usage {
196                usage: Value::Object(clone),
197            })
198        }
199        "error" => {
200            let message = obj
201                .get("message")
202                .and_then(Value::as_str)
203                .ok_or_else(|| "provider stream error missing message".to_string())?;
204            if message.is_empty() {
205                return Err("provider stream error message must be non-empty".to_string());
206            }
207            Ok(ProviderStreamEvent::Error {
208                message: message.to_string(),
209            })
210        }
211        "done" => Ok(ProviderStreamEvent::Done),
212        other => Err(format!("unknown provider stream event type: {other}")),
213    }
214}
215
216pub async fn execute_provider_tool_use(
217    registry: &crate::ToolRegistry,
218    session_tools: &crate::tools::activation::SessionToolSet,
219    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
220    tool_use: ProviderToolUse,
221    ctx: crate::ToolContext,
222    max_tool_output: usize,
223) -> Value {
224    let tool_id = tool_use.id;
225    let tool_name = tool_use.name;
226    let input = tool_use.input;
227
228    // ═══ EXECUTION GATE (Task 16, spec §7.1) ═══
229    // The extension-provider interior tool loop passes the SAME gate as
230    // stream dispatch: resolve the provider-supplied wire name to the exact
231    // live ToolId, verify the session set snapshot generation + pinned
232    // schema digest, require core/exact-grant status, re-check source
233    // trust, and only then acquire the implementation — all against the
234    // one consistent registry borrow the caller holds (read-locked for the
235    // whole outer provider call, so the catalog cannot drift inside the
236    // loop). Denial happens BEFORE input translation, BEFORE hook
237    // emission, BEFORE execution, and returns a valid tool_result with
238    // typed bounded static content and `is_error: true`.
239    let authorized = match crate::tools::activation::ExecutionGate::authorize_wire_call(
240        registry,
241        session_tools,
242        &tool_name,
243    ) {
244        Ok(authorized) => authorized,
245        Err(denial) => {
246            return serde_json::json!({
247                "type": "tool_result",
248                "tool_use_id": tool_id,
249                "content": denial.to_string(),
250                "is_error": true,
251            });
252        }
253    };
254    let tool = authorized.implementation();
255
256    let runtime_name = authorized.runtime_name().to_string();
257    let input = registry.translate_input_for_api_tool(&tool_name, input);
258    let decision = crate::runtime::resolve_before_tool_call_decision(
259        input.clone(),
260        crate::runtime::emit_before_tool_call(
261            hook_bus,
262            &tool_name,
263            Some(&runtime_name),
264            input.clone(),
265            // Extension-originated tool.invoke: no owning conversation on this
266            // path (ToolContext carries none) → `null`, as before.
267            None,
268        )
269        .await,
270        ctx.capabilities.secret_prompt.as_ref(),
271        false,
272        // The session's latch, so "Allow all" here arms (and honours) the
273        // same per-extension consent as the main tool path.
274        ctx.capabilities.session_allow_all.as_deref(),
275    )
276    .await;
277
278    let crate::runtime::BeforeToolCallDecision::Continue { input } = decision else {
279        let crate::runtime::BeforeToolCallDecision::Block { reason } = decision else {
280            unreachable!()
281        };
282        return serde_json::json!({
283            "type": "tool_result",
284            "tool_use_id": tool_id,
285            "content": format!("Tool call blocked by extension: {}", reason),
286            "is_error": true,
287        });
288    };
289
290    let input_for_hook = input.clone();
291    let (result, is_error) = match tool.execute(input, ctx).await {
292        Ok(output) => (output, false),
293        Err(error) => (format!("Tool execution failed: {}", error), true),
294    };
295    let result = crate::runtime::emit_after_tool_call(
296        hook_bus,
297        &tool_name,
298        Some(&runtime_name),
299        input_for_hook,
300        result,
301        max_tool_output,
302        None,
303    )
304    .await;
305
306    let mut response = serde_json::json!({
307        "type": "tool_result",
308        "tool_use_id": tool_id,
309        "content": crate::truncate_str(&result, max_tool_output).to_string(),
310    });
311    if is_error {
312        response["is_error"] = serde_json::json!(true);
313    }
314    response
315}
316
317/// Interior extension-provider tool loop. `session_tools` is built ONCE by
318/// the caller (under the same registry read guard passed as `registry`) and
319/// reused for every tool call in every interior round — the registry stays
320/// read-locked for the whole outer provider call, so no catalog drift is
321/// possible inside this loop and one set snapshot governs all rounds.
322#[allow(clippy::too_many_arguments)] // explicit loop policy inputs; grouping would hide registry/session coupling
323pub async fn complete_provider_with_tools<F>(
324    handler: Arc<dyn ExtensionHandler>,
325    mut params: ProviderCompleteParams,
326    registry: &crate::ToolRegistry,
327    session_tools: &crate::tools::activation::SessionToolSet,
328    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
329    mut context_factory: F,
330    max_tool_output: usize,
331    max_iterations: usize,
332    // Out-param: total tool calls the provider requested across every
333    // interior round. An out-param (rather than a return tuple) so the
334    // count is still observable on the error paths below — the audit
335    // record must stay honest when the iteration limit is hit.
336    tools_requested: &mut u32,
337) -> Result<ProviderCompleteResult, String>
338where
339    F: FnMut() -> crate::ToolContext,
340{
341    let max_iterations = max_iterations.max(1);
342    *tools_requested = 0;
343    for iteration in 0..max_iterations {
344        let result = handler.provider_complete(params.clone()).await?;
345        let tool_uses = extract_provider_tool_uses(&result.content)?;
346        if tool_uses.is_empty() {
347            return Ok(result);
348        }
349        // Counted before the limit check: the provider DID request these,
350        // even when we refuse to run another round.
351        *tools_requested = tools_requested.saturating_add(tool_uses.len() as u32);
352        if iteration + 1 == max_iterations {
353            return Err(format!(
354                "extension provider '{}' exceeded provider tool-use iteration limit ({})",
355                handler.id(),
356                max_iterations,
357            ));
358        }
359
360        let assistant_content = result.content.clone();
361        params.messages.push(std::sync::Arc::new(serde_json::json!({
362            "role": "assistant",
363            "content": assistant_content,
364        })));
365
366        let mut tool_results = Vec::with_capacity(tool_uses.len());
367        for tool_use in tool_uses {
368            tool_results.push(
369                execute_provider_tool_use(
370                    registry,
371                    session_tools,
372                    hook_bus,
373                    tool_use,
374                    context_factory(),
375                    max_tool_output,
376                )
377                .await,
378            );
379        }
380        params.messages.push(std::sync::Arc::new(serde_json::json!({
381            "role": "user",
382            "content": tool_results,
383        })));
384    }
385    Err(format!(
386        "extension provider '{}' exceeded provider tool-use iteration limit ({})",
387        handler.id(),
388        max_iterations,
389    ))
390}
391
392pub fn extract_provider_tool_uses(content: &[Value]) -> Result<Vec<ProviderToolUse>, String> {
393    let mut tool_uses = Vec::new();
394    for block in content {
395        if block.get("type").and_then(Value::as_str) != Some("tool_use") {
396            continue;
397        }
398        let id = block
399            .get("id")
400            .and_then(Value::as_str)
401            .ok_or_else(|| "provider tool_use missing id".to_string())?;
402        let name = block
403            .get("name")
404            .and_then(Value::as_str)
405            .ok_or_else(|| "provider tool_use missing name".to_string())?;
406        if id.trim().is_empty() {
407            return Err("provider tool_use id is empty".to_string());
408        }
409        if name.trim().is_empty() {
410            return Err("provider tool_use name is empty".to_string());
411        }
412        let input = block
413            .get("input")
414            .cloned()
415            .unwrap_or_else(|| serde_json::json!({}));
416        if !input.is_object() {
417            return Err(format!(
418                "provider tool_use '{}' input must be a JSON object",
419                id
420            ));
421        }
422        tool_uses.push(ProviderToolUse {
423            id: id.to_string(),
424            name: name.to_string(),
425            input,
426        });
427    }
428    Ok(tool_uses)
429}
430
431#[derive(Debug, Clone, Default, PartialEq)]
432pub struct InitializeCapabilitiesResult {
433    pub tools: Vec<RegisteredExtensionToolSpec>,
434    pub providers: Vec<RegisteredProviderSpec>,
435    /// Generic, plugin-defined capabilities. Each entry's `kind` is a
436    /// free-form string the plugin author picks (e.g. `"capture"`,
437    /// `"ocr"`, `"agent"`, `"foot_pedal"`); core does not enumerate or
438    /// branch on it. Permissions are gated by the declared
439    /// [`CapabilityDeclaration::permissions`] list.
440    pub capabilities: Vec<CapabilityDeclaration>,
441}
442
443/// Generic capability declaration returned by an extension's
444/// `initialize` response.
445///
446/// Core does not interpret `kind` or `params` — those are contracts
447/// between the plugin and whatever consumer (chatui glue, another
448/// plugin, an external watcher) cares about that capability kind. The
449/// host only enforces that the plugin holds every permission it
450/// declares it needs.
451#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
452pub struct CapabilityDeclaration {
453    /// Free-form capability kind tag chosen by the plugin author.
454    /// Examples: `"capture"`, `"ocr"`, `"agent"`, `"clipboard_mirror"`.
455    /// Must be non-empty; otherwise opaque to core.
456    pub kind: String,
457    /// Human-readable display name shown in `/extensions status` and
458    /// similar surfaces. Must be non-empty.
459    pub name: String,
460    /// Permission names this capability needs at runtime, e.g.
461    /// `["audio.input"]`. Every entry must parse to a known
462    /// [`crate::extensions::permissions::Permission`] *and* be present
463    /// in the extension's granted permission set.
464    #[serde(default)]
465    pub permissions: Vec<String>,
466    /// Free-form metadata. Core stores and forwards but never
467    /// interprets. Plugins use this for endpoint URLs, mode lists,
468    /// version tags, anything that doesn't fit the typed slots.
469    #[serde(default, skip_serializing_if = "is_null_value")]
470    pub params: serde_json::Value,
471}
472
473fn is_null_value(v: &serde_json::Value) -> bool {
474    v.is_null()
475}
476
477/// Validate a [`CapabilityDeclaration`] against the granted permission
478/// set.
479///
480/// Rules — generic, no enumeration of capability kinds:
481/// - `kind` must be non-empty.
482/// - `name` must be non-empty.
483/// - Every string in `permissions` must parse to a known
484///   [`crate::extensions::permissions::Permission`].
485/// - Every parsed permission must be present in `granted`.
486pub fn validate_capability(
487    decl: &CapabilityDeclaration,
488    granted: &crate::extensions::permissions::PermissionSet,
489) -> Result<(), String> {
490    use crate::extensions::permissions::Permission;
491    if decl.kind.trim().is_empty() {
492        return Err("capability 'kind' must be non-empty".to_string());
493    }
494    if decl.name.trim().is_empty() {
495        return Err("capability 'name' must be non-empty".to_string());
496    }
497    for perm_name in &decl.permissions {
498        let parsed = Permission::parse(perm_name).ok_or_else(|| {
499            format!(
500                "capability '{}' declares unknown permission '{}'",
501                decl.kind, perm_name
502            )
503        })?;
504        if !granted.has(parsed) {
505            return Err(format!(
506                "capability '{}' requires permission '{}' but it is not granted",
507                decl.kind, perm_name
508            ));
509        }
510    }
511    Ok(())
512}
513
514#[derive(Deserialize)]
515struct InitializeResult {
516    protocol_version: u32,
517    #[serde(default)]
518    capabilities: InitializeCapabilities,
519}
520
521#[derive(Default, Deserialize)]
522struct InitializeCapabilities {
523    #[serde(default)]
524    tools: Vec<RegisteredExtensionToolSpec>,
525    #[serde(default)]
526    providers: Vec<RegisteredProviderSpec>,
527    /// Canonical generic capability list.
528    #[serde(default)]
529    capabilities: Vec<CapabilityDeclaration>,
530}
531
532/// A JSON-RPC notification frame received from an extension (no `id`).
533///
534/// Internal API exposed publicly (`#[doc(hidden)]`) so integration tests
535/// can subscribe to notifications via [`ProcessExtension::subscribe_notifications`].
536/// Extension authors should not depend on this type — it may change without notice.
537#[doc(hidden)]
538#[derive(Debug, Clone)]
539pub struct NotificationFrame {
540    pub method: String,
541    pub params: Value,
542}
543
544/// CP-11 fix-2 (B): bounded notification handoff. Combined with the 4 MiB
545/// per-frame reader cap, queue capacity fixes the worst-case bytes parked
546/// between the sidecar reader and a subscriber; beyond it the reader
547/// AWAITS (backpressure onto the child's stdout pipe) instead of retaining
548/// hostile flood frames in host memory.
549pub const NOTIFICATION_QUEUE_CAPACITY: usize = 8;
550/// CP-11 fix-2 (B): bounded provider stream-event handoff between the
551/// `provider_stream` notification loop and the route forwarder.
552pub const PROVIDER_EVENT_QUEUE_CAPACITY: usize = 8;
553
554/// Shared mailbox for the background reader task. Holds in-flight request
555/// senders (keyed by JSON-RPC id) and an optional notification subscriber.
556///
557/// Persists across process restarts: a new reader task replaces the old one
558/// but the `Inbox` itself is reused. Pending requests are drained with
559/// errors when the reader observes EOF or a transport failure.
560struct Inbox {
561    pending: Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>,
562    /// Fan-out notification subscribers. Each subscriber is keyed by a monotonic
563    /// id so it can unsubscribe itself without disturbing other concurrent
564    /// subscribers (e.g. background watchers that overlap with
565    /// `command.invoke` / `provider.stream`). Dead senders (receiver dropped)
566    /// are pruned lazily on dispatch and on `unsubscribe_notifications`.
567    /// BOUNDED ([`NOTIFICATION_QUEUE_CAPACITY`]): a subscriber that stops
568    /// draining backpressures the reader instead of growing host memory.
569    notification_sinks: Mutex<Vec<(usize, mpsc::Sender<NotificationFrame>)>>,
570    /// Monotonic id allocator for `notification_sinks`. Wraparound is not a
571    /// concern in practice — `usize` is enormous relative to subscription
572    /// lifetimes — but uniqueness is only required among live subscribers.
573    next_sink_id: std::sync::atomic::AtomicUsize,
574    /// Set to true when the reader task exits (EOF or error). Used to prevent
575    /// callers from registering pending requests that will never be fulfilled.
576    closed: std::sync::atomic::AtomicBool,
577    /// Child observation is separate from `ProcessExtension::state`: that lock
578    /// serializes RPC I/O and may be held for an entire request. The lifecycle
579    /// slot is held only to clone/publish a handle, never across process I/O.
580    lifecycle: Mutex<Option<Arc<ProcessLifecycle>>>,
581    /// Even generations identify live transports; the low bit permanently
582    /// invalidates a transport. Allocation fails closed rather than wrapping.
583    next_generation: AtomicU64,
584    /// Permissions granted to the calling extension. Set after manifest
585    /// validation; checked by inbound RPC handlers (e.g. memory.append).
586    permissions: RwLock<Option<crate::extensions::permissions::PermissionSet>>,
587    /// Stdin handle of the currently-running child process. Used by the
588    /// reader task to write JSON-RPC responses for inbound requests.
589    /// Replaced on each spawn (initial spawn + restarts).
590    inbound_stdin: Mutex<Option<Arc<Mutex<ChildStdin>>>>,
591    /// Extension id, used for namespace policy and diagnostics.
592    extension_id: String,
593    exclusive_memory: bool,
594}
595
596impl Inbox {
597    fn new(extension_id: String) -> Self {
598        Self {
599            pending: Mutex::new(HashMap::new()),
600            notification_sinks: Mutex::new(Vec::new()),
601            next_sink_id: std::sync::atomic::AtomicUsize::new(0),
602            closed: std::sync::atomic::AtomicBool::new(false),
603            lifecycle: Mutex::new(None),
604            next_generation: AtomicU64::new(2),
605            permissions: RwLock::new(None),
606            inbound_stdin: Mutex::new(None),
607            exclusive_memory: crate::memory_backend::MemoryBinding::configured_current()
608                .exclusive(),
609            extension_id,
610        }
611    }
612
613    async fn invalidate_lifecycle(&self) {
614        if let Some(lifecycle) = self.lifecycle.lock().await.as_ref() {
615            lifecycle.invalidate();
616        }
617    }
618
619    /// Drains all pending request senders, sending `Err(reason)` to each.
620    /// Also marks the inbox as closed so no new requests can be registered.
621    async fn fail_all_pending(&self, reason: &str) {
622        self.invalidate_lifecycle().await;
623        self.closed
624            .store(true, std::sync::atomic::Ordering::Release);
625        let drained: Vec<_> = {
626            let mut pending = self.pending.lock().await;
627            pending.drain().collect()
628        };
629        for (_, tx) in drained {
630            let _ = tx.send(Err(reason.to_string()));
631        }
632    }
633}
634
635/// One actual child/transport lifetime. Reader tasks retain this exact record,
636/// so a late EOF from an old reader can never invalidate a replacement's token.
637struct ProcessLifecycle {
638    child: Mutex<Child>,
639    generation: AtomicU64,
640    initialized: AtomicBool,
641}
642
643impl ProcessLifecycle {
644    fn invalidate(&self) {
645        // Idempotent; no wraparound and no window that exposes the old token
646        // as healthy while teardown or replacement is already underway.
647        self.generation.fetch_or(1, Ordering::AcqRel);
648    }
649
650    fn snapshot(&self, recovered: bool) -> Option<ExtensionLifecycle> {
651        if self.generation.load(Ordering::Acquire) & 1 == 0 {
652            let mut child = self.child.try_lock().ok()?;
653            match child.try_wait() {
654                Ok(None) => {}
655                Ok(Some(_)) | Err(_) => self.invalidate(),
656            }
657        }
658        let generation = self.generation.load(Ordering::Acquire);
659        let health = if generation & 1 != 0 {
660            ExtensionHealth::Failed
661        } else if !self.initialized.load(Ordering::Acquire) {
662            ExtensionHealth::Loaded
663        } else if recovered {
664            ExtensionHealth::Degraded
665        } else {
666            ExtensionHealth::Running
667        };
668        Some(ExtensionLifecycle { generation, health })
669    }
670}
671
672struct ProcessState {
673    lifecycle: Arc<ProcessLifecycle>,
674    stdin: Arc<Mutex<ChildStdin>>,
675    reader_handle: JoinHandle<()>,
676}
677
678impl Drop for ProcessState {
679    fn drop(&mut self) {
680        self.lifecycle.invalidate();
681        self.reader_handle.abort();
682        // `kill_on_drop` alone is insufficient: the lifecycle observation slot
683        // also owns the child. Request cancellation must stop it immediately.
684        if let Ok(mut child) = self.lifecycle.child.try_lock() {
685            let _ = child.start_kill();
686        }
687    }
688}
689
690/// A running extension process communicating via JSON-RPC 2.0 over stdio.
691pub struct ProcessExtension {
692    id: String,
693    command: String,
694    args: Vec<String>,
695    cwd: Option<PathBuf>,
696    state: Arc<Mutex<Option<ProcessState>>>,
697    /// Serializes a full request/response exchange and restart attempts.
698    call_lock: Arc<Mutex<()>>,
699    next_id: AtomicU64,
700    restart_count: AtomicUsize,
701    /// Last resolved initialize config. Kept in memory only so a restarted
702    /// extension receives the same manifest-resolved values (including secrets).
703    /// Never written to disk or emitted in logs.
704    initialize_config: Mutex<Value>,
705    /// Lifetime restart total — never reset. `restart_count` tracks
706    /// *consecutive* failures (reset on successful restart, H-8) for the
707    /// exhaustion budget; this one feeds health/reporting so a previously
708    /// crashed extension still reads Degraded instead of pretending it
709    /// never crashed.
710    total_restarts: AtomicUsize,
711    /// Set by [`ExtensionHandler::shutdown`] (unload, reload, lease
712    /// teardown), never cleared: a stopped handler must not respawn. A call
713    /// authorized against it before the stop (a tool call batched with the
714    /// `reload-extension` that replaces it) would otherwise take the
715    /// empty-state restart path and run a second copy of the extension
716    /// beside its replacement.
717    stopped: AtomicBool,
718    /// Restart policy controlling exponential backoff and budget.
719    pub(crate) restart_policy: RestartPolicy,
720    /// Shared mailbox between the reader task and request callers. Persists
721    /// across process restarts so that any active notification subscriber
722    /// survives a restart-on-error.
723    inbox: Arc<Inbox>,
724}
725
726/// Prefix the reader task gives every JSON-RPC `error` response from the
727/// extension. Such an error proves the process is alive and answering, so the
728/// call-with-restart path must return it verbatim instead of restarting.
729pub(crate) const APPLICATION_ERROR_PREFIX: &str = "Extension error: ";
730
731/// Whether a call error is an application-level JSON-RPC error (the process
732/// answered) rather than a transport failure (EOF, write error, dropped
733/// response channel, timeout).
734pub(crate) fn is_application_error(error: &str) -> bool {
735    error.starts_with(APPLICATION_ERROR_PREFIX)
736}
737
738impl ProcessExtension {
739    /// Abort the child transport immediately. Cancellation uses this rather
740    /// than waiting for the per-call permit; draining pending responders
741    /// releases every blocked call/producer.
742    pub async fn force_shutdown(&self) {
743        self.inbox.invalidate_lifecycle().await;
744        let mut state_guard = self.state.lock().await;
745        if let Some(state) = state_guard.take() {
746            state.reader_handle.abort();
747            state.lifecycle.invalidate();
748            let _ = state.lifecycle.child.lock().await.kill().await;
749        }
750        drop(state_guard);
751        self.inbox.closed.store(true, Ordering::Release);
752        self.inbox.notification_sinks.lock().await.clear();
753        self.inbox
754            .fail_all_pending("transport closed: extension cancelled")
755            .await;
756    }
757
758    pub async fn spawn(id: &str, command: &str, args: &[String]) -> Result<Self, String> {
759        Self::spawn_with_cwd(id, command, args, None).await
760    }
761
762    /// Spawn `command` with `args` and optional working directory.
763    ///
764    /// Child stderr is captured and forwarded to debug tracing with the extension
765    /// id so extension authors can inspect diagnostics without corrupting stdout.
766    pub async fn spawn_with_cwd(
767        id: &str,
768        command: &str,
769        args: &[String],
770        cwd: Option<PathBuf>,
771    ) -> Result<Self, String> {
772        let exclusive = crate::memory_backend::MemoryBinding::configured_current().exclusive();
773        Self::spawn_with_memory_policy(id, command, args, cwd, exclusive).await
774    }
775
776    pub(crate) async fn spawn_with_memory_policy(
777        id: &str,
778        command: &str,
779        args: &[String],
780        cwd: Option<PathBuf>,
781        exclusive: bool,
782    ) -> Result<Self, String> {
783        if exclusive && id == "axel-memory-manager" {
784            return Err(
785                "independent Axel plugin is disabled by the host-selected memory backend".into(),
786            );
787        }
788        let mut inbox = Inbox::new(id.to_string());
789        inbox.exclusive_memory = exclusive;
790        let inbox = Arc::new(inbox);
791        let state = Self::spawn_state(id, command, args, cwd.as_ref(), inbox.clone()).await?;
792        Ok(Self {
793            id: id.to_string(),
794            command: command.to_string(),
795            args: args.to_vec(),
796            cwd,
797            state: Arc::new(Mutex::new(Some(state))),
798            call_lock: Arc::new(Mutex::new(())),
799            next_id: AtomicU64::new(1),
800            restart_count: AtomicUsize::new(0),
801            initialize_config: Mutex::new(Value::Object(Default::default())),
802            total_restarts: AtomicUsize::new(0),
803            stopped: AtomicBool::new(false),
804            restart_policy: RestartPolicy::default(),
805            inbox,
806        })
807    }
808
809    /// Override the restart policy. Intended for tests.
810    pub fn with_restart_policy(mut self, policy: RestartPolicy) -> Self {
811        self.restart_policy = policy;
812        self
813    }
814
815    async fn spawn_state(
816        id: &str,
817        command: &str,
818        args: &[String],
819        cwd: Option<&PathBuf>,
820        inbox: Arc<Inbox>,
821    ) -> Result<ProcessState, String> {
822        let mut cmd = Command::new(command);
823        cmd.args(args)
824            .stdin(Stdio::piped())
825            .stdout(Stdio::piped())
826            .stderr(Stdio::piped());
827        if let Some(cwd) = cwd {
828            cmd.current_dir(cwd);
829        }
830
831        // Clear the inherited environment so extensions cannot read secrets from
832        // the parent process (e.g. ANTHROPIC_API_KEY, SSH_AUTH_SOCK, AWS_*).
833        // Only forward a minimal set of safe, non-sensitive variables.
834        // TODO: forward the extension's declared `secret_env` values here once
835        //       secret_env is resolved at spawn time rather than config time.
836        cmd.env_clear();
837        for var in &["PATH", "HOME", "LANG", "TERM", "XDG_RUNTIME_DIR"] {
838            if let Ok(val) = std::env::var(var) {
839                cmd.env(var, val);
840            }
841        }
842
843        cmd.kill_on_drop(true);
844
845        let mut child = cmd
846            .spawn()
847            .map_err(|e| format!("Failed to spawn extension '{}': {}", id, e))?;
848
849        let stdin = child
850            .stdin
851            .take()
852            .ok_or_else(|| format!("No stdin for extension '{}'", id))?;
853        let stdout = child
854            .stdout
855            .take()
856            .ok_or_else(|| format!("No stdout for extension '{}'", id))?;
857        if let Some(stderr) = child.stderr.take() {
858            let extension_id = id.to_string();
859            tokio::spawn(async move {
860                // Bounded stderr forwarding (Task 20): extension-controlled
861                // bytes. Each line is capped, total forwarded bytes are
862                // budgeted; past the budget only byte counts are tracked so
863                // a hostile child can never grow memory or flood the log.
864                const MAX_STDERR_LINE: usize = 8 * 1024;
865                const MAX_STDERR_FORWARD_TOTAL: u64 = 128 * 1024;
866                let mut reader = BufReader::new(stderr);
867                let mut forwarded: u64 = 0;
868                let mut suppressed: u64 = 0;
869                loop {
870                    let mut buf: Vec<u8> = Vec::new();
871                    let mut limited =
872                        tokio::io::AsyncReadExt::take(&mut reader, MAX_STDERR_LINE as u64 + 1);
873                    match tokio::io::AsyncBufReadExt::read_until(&mut limited, b'\n', &mut buf)
874                        .await
875                    {
876                        Ok(0) => break,
877                        Ok(n) => {
878                            if forwarded < MAX_STDERR_FORWARD_TOTAL {
879                                forwarded = forwarded.saturating_add(n as u64);
880                                let truncated = n > MAX_STDERR_LINE;
881                                let end = buf.len().min(MAX_STDERR_LINE);
882                                let line = String::from_utf8_lossy(&buf[..end]);
883                                let line = line.trim_end_matches(['\r', '\n']);
884                                tracing::debug!(
885                                    extension = %extension_id,
886                                    truncated,
887                                    stderr = %line,
888                                );
889                            } else {
890                                suppressed = suppressed.saturating_add(n as u64);
891                            }
892                        }
893                        Err(error) => {
894                            tracing::debug!(
895                                extension = %extension_id,
896                                error = %error,
897                                "Failed to read extension stderr",
898                            );
899                            break;
900                        }
901                    }
902                }
903                if suppressed > 0 {
904                    tracing::debug!(
905                        extension = %extension_id,
906                        suppressed_bytes = suppressed,
907                        "Extension stderr exceeded the forwarding budget; further bytes counted only",
908                    );
909                }
910            });
911        }
912
913        let generation = inbox
914            .next_generation
915            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| n.checked_add(2))
916            .map_err(|_| "extension lifecycle generation exhausted".to_string())?;
917        let lifecycle = Arc::new(ProcessLifecycle {
918            child: Mutex::new(child),
919            generation: AtomicU64::new(generation),
920            initialized: AtomicBool::new(false),
921        });
922        {
923            let mut slot = inbox.lifecycle.lock().await;
924            if let Some(old) = slot.as_ref() {
925                old.invalidate();
926            }
927            *slot = Some(lifecycle.clone());
928        }
929        // Reset BEFORE starting the reader: a fast EOF must never be erased.
930        inbox.closed.store(false, Ordering::Release);
931        let reader_handle =
932            Self::spawn_reader(stdout, inbox.clone(), id.to_string(), lifecycle.clone());
933
934        let stdin_arc = Arc::new(Mutex::new(stdin));
935        // Publish current stdin into the inbox so the reader task can write
936        // JSON-RPC responses for inbound requests (e.g. memory.append).
937        *inbox.inbound_stdin.lock().await = Some(stdin_arc.clone());
938
939        Ok(ProcessState {
940            lifecycle,
941            stdin: stdin_arc,
942            reader_handle,
943        })
944    }
945
946    /// Spawn the background reader task that owns `stdout`, demultiplexing
947    /// JSON-RPC responses (by id) and notifications (no id) into the shared
948    /// [`Inbox`]. Returns a `JoinHandle` so callers can `.abort()` it on
949    /// restart or shutdown.
950    fn spawn_reader(
951        stdout: ChildStdout,
952        inbox: Arc<Inbox>,
953        extension_id: String,
954        lifecycle: Arc<ProcessLifecycle>,
955    ) -> JoinHandle<()> {
956        tokio::spawn(async move {
957            let mut reader = BufReader::new(stdout);
958            loop {
959                match Self::read_one_frame(&mut reader, &extension_id).await {
960                    Ok(Some(value)) => {
961                        Self::dispatch_frame(value, &inbox, &extension_id).await;
962                    }
963                    Ok(None) => {
964                        lifecycle.invalidate();
965                        tracing::debug!(
966                            extension = %extension_id,
967                            "Extension stdout closed (EOF); failing pending requests",
968                        );
969                        inbox.fail_all_pending("transport closed: EOF").await;
970                        // Drop all notification subscribers on EOF.
971                        inbox.notification_sinks.lock().await.clear();
972                        return;
973                    }
974                    Err(error) => {
975                        lifecycle.invalidate();
976                        tracing::debug!(
977                            extension = %extension_id,
978                            error = %error,
979                            "Extension transport read error",
980                        );
981                        inbox
982                            .fail_all_pending(&format!("transport error: {}", error))
983                            .await;
984                        inbox.notification_sinks.lock().await.clear();
985                        return;
986                    }
987                }
988            }
989        })
990    }
991
992    /// Read one Content-Length-framed JSON message from `reader`. Returns
993    /// `Ok(None)` on a clean EOF *before* any header bytes are read; any
994    /// other unexpected EOF is reported as `Err`.
995    async fn read_one_frame(
996        reader: &mut BufReader<ChildStdout>,
997        extension_id: &str,
998    ) -> Result<Option<Value>, String> {
999        let mut content_length: Option<usize> = None;
1000        let mut saw_any_header = false;
1001        loop {
1002            let mut header_line = String::new();
1003            // Bounded header read: a hostile child streaming bytes without
1004            // a newline must fail the 1 KiB header policy, not grow memory.
1005            let n = {
1006                let mut limited = tokio::io::AsyncReadExt::take(&mut *reader, 1025);
1007                limited
1008                    .read_line(&mut header_line)
1009                    .await
1010                    .map_err(|e| format!("Read header error: {}", e))?
1011            };
1012            if n == 0 {
1013                if saw_any_header {
1014                    return Err("Unexpected EOF while reading response headers".into());
1015                }
1016                return Ok(None);
1017            }
1018            saw_any_header = true;
1019            if header_line.len() > 1024 {
1020                return Err(format!(
1021                    "Extension '{}' header line too long ({} bytes)",
1022                    extension_id,
1023                    header_line.len()
1024                ));
1025            }
1026            let trimmed = header_line.trim();
1027            if trimmed.is_empty() {
1028                break;
1029            }
1030            if let Some((name, value)) = trimmed.split_once(':') {
1031                if name.trim().eq_ignore_ascii_case("Content-Length") {
1032                    content_length = Some(value.trim().parse().map_err(|_| {
1033                        format!("Invalid Content-Length value: {:?}", value.trim())
1034                    })?);
1035                }
1036            }
1037        }
1038        let content_length = content_length.ok_or_else(|| {
1039            format!(
1040                "Extension '{}' frame missing Content-Length header",
1041                extension_id
1042            )
1043        })?;
1044        const MAX_RESPONSE_SIZE: usize = 4 * 1024 * 1024;
1045        if content_length > MAX_RESPONSE_SIZE {
1046            return Err(format!(
1047                "Extension '{}' frame too large: {} bytes (max {})",
1048                extension_id, content_length, MAX_RESPONSE_SIZE
1049            ));
1050        }
1051        let mut buf = vec![0u8; content_length];
1052        tokio::io::AsyncReadExt::read_exact(reader, &mut buf)
1053            .await
1054            .map_err(|e| format!("Read body error: {}", e))?;
1055        let value: Value =
1056            serde_json::from_slice(&buf).map_err(|e| format!("Parse frame error: {}", e))?;
1057        Ok(Some(value))
1058    }
1059
1060    /// Route a parsed JSON-RPC frame to the right consumer:
1061    /// - response (`id` numeric, no `method`) → matching pending oneshot
1062    /// - request (`id` numeric and `method`) → inbound request handler
1063    /// - notification (`method` set, no `id`) → notification subscriber
1064    /// - anything else → trace and drop
1065    async fn dispatch_frame(value: Value, inbox: &Arc<Inbox>, extension_id: &str) {
1066        let id_field = value.get("id");
1067        let id_is_present = !matches!(id_field, None | Some(Value::Null));
1068        let method_field = value
1069            .get("method")
1070            .and_then(Value::as_str)
1071            .map(str::to_string);
1072
1073        if id_is_present && method_field.is_some() {
1074            // Inbound request from the extension. Spawn a task to handle it
1075            // so the reader loop is never blocked on memory I/O or other work.
1076            // JSON-RPC 2.0 §4: `id` MUST be a String, Number, or Null. The
1077            // extension picks its own request ids and is the only party that
1078            // correlates them, so echo whatever it sent back verbatim rather
1079            // than forcing it through u64.
1080            //
1081            // This previously required a numeric id and dropped everything
1082            // else at trace level. `docs/extensions/protocol.md` shows string
1083            // ids in its own examples, so an extension written against the
1084            // documentation hung forever with no diagnostic. (#291 defect 3)
1085            let id = match id_field {
1086                Some(id @ (Value::String(_) | Value::Number(_))) => id.clone(),
1087                _ => {
1088                    tracing::warn!(
1089                        extension = %extension_id,
1090                        frame = %value,
1091                        "Discarding inbound request: JSON-RPC id must be a string or a number",
1092                    );
1093                    return;
1094                }
1095            };
1096            let Some(method) = method_field else { return };
1097            let params = value.get("params").cloned().unwrap_or(Value::Null);
1098            let inbox = inbox.clone();
1099            let extension_id = extension_id.to_string();
1100            tokio::spawn(async move {
1101                let outcome = Self::handle_inbound_request(&inbox, &method, params).await;
1102                let payload = match outcome {
1103                    Ok(result) => serde_json::json!({
1104                        "jsonrpc": "2.0",
1105                        "id": id,
1106                        "result": result,
1107                    }),
1108                    Err((code, message)) => serde_json::json!({
1109                        "jsonrpc": "2.0",
1110                        "id": id,
1111                        "error": {"code": code, "message": message},
1112                    }),
1113                };
1114                let stdin_handle = inbox.inbound_stdin.lock().await.clone();
1115                if let Some(stdin) = stdin_handle {
1116                    let body = match serde_json::to_string(&payload) {
1117                        Ok(s) => s,
1118                        Err(error) => {
1119                            tracing::warn!(
1120                                extension = %extension_id,
1121                                error = %error,
1122                                "Failed to serialize inbound response",
1123                            );
1124                            return;
1125                        }
1126                    };
1127                    let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
1128                    let mut stdin = stdin.lock().await;
1129                    if let Err(error) = stdin.write_all(frame.as_bytes()).await {
1130                        inbox.invalidate_lifecycle().await;
1131                        tracing::warn!(
1132                            extension = %extension_id,
1133                            error = %error,
1134                            "Failed to write inbound response",
1135                        );
1136                        return;
1137                    }
1138                    if let Err(error) = stdin.flush().await {
1139                        inbox.invalidate_lifecycle().await;
1140                        tracing::warn!(
1141                            extension = %extension_id,
1142                            error = %error,
1143                            "Failed to flush inbound response",
1144                        );
1145                    }
1146                } else {
1147                    tracing::warn!(
1148                        extension = %extension_id,
1149                        "No stdin available to reply to inbound request",
1150                    );
1151                }
1152            });
1153            return;
1154        }
1155
1156        if id_is_present {
1157            // Response frames are different: the id must be one WE issued, and
1158            // the runtime only ever issues numeric ids. A non-numeric id here
1159            // cannot correlate to anything, so it is genuinely a protocol
1160            // error on the extension's side — say so at warn, not trace.
1161            let id = match id_field.and_then(Value::as_u64) {
1162                Some(id) => id,
1163                None => {
1164                    tracing::warn!(
1165                        extension = %extension_id,
1166                        frame = %value,
1167                        "Discarding response frame: id does not match any request \
1168                         the runtime issued (runtime request ids are always numeric)",
1169                    );
1170                    return;
1171                }
1172            };
1173            let sender = inbox.pending.lock().await.remove(&id);
1174            match sender {
1175                Some(tx) => {
1176                    let payload = if let Some(err) = value.get("error") {
1177                        let message = err
1178                            .get("message")
1179                            .and_then(Value::as_str)
1180                            .unwrap_or("unknown extension error")
1181                            .to_string();
1182                        Err(format!("{APPLICATION_ERROR_PREFIX}{}", message))
1183                    } else {
1184                        Ok(value.get("result").cloned().unwrap_or(Value::Null))
1185                    };
1186                    let _ = tx.send(payload);
1187                }
1188                None => {
1189                    tracing::trace!(
1190                        extension = %extension_id,
1191                        id = id,
1192                        "Response with unknown id (no pending request); dropping",
1193                    );
1194                }
1195            }
1196        } else if let Some(method) = value.get("method").and_then(Value::as_str) {
1197            let params = value.get("params").cloned().unwrap_or(Value::Null);
1198            let frame = NotificationFrame {
1199                method: method.to_string(),
1200                params,
1201            };
1202            // Snapshot the live subscribers WITHOUT holding the lock across
1203            // the awaited sends below — a subscriber unsubscribing while we
1204            // backpressure must not deadlock against this dispatch.
1205            let targets: Vec<(usize, mpsc::Sender<NotificationFrame>)> = {
1206                let sinks = inbox.notification_sinks.lock().await;
1207                sinks.to_vec()
1208            };
1209            if targets.is_empty() {
1210                tracing::trace!(
1211                    extension = %extension_id,
1212                    method = %method,
1213                    "Notification with no active subscribers; dropping",
1214                );
1215            } else {
1216                // Bounded fan-out: an un-draining subscriber BACKPRESSURES
1217                // this reader (and, through the stdout pipe, the child)
1218                // instead of growing host memory. `send` only errors when
1219                // the receiver is gone, so failure == dead subscriber.
1220                let mut dead = Vec::new();
1221                for (id, tx) in &targets {
1222                    if tx.send(frame.clone()).await.is_err() {
1223                        dead.push(*id);
1224                    }
1225                }
1226                if !dead.is_empty() {
1227                    let mut sinks = inbox.notification_sinks.lock().await;
1228                    sinks.retain(|(id, _)| !dead.contains(id));
1229                }
1230            }
1231        } else {
1232            tracing::trace!(
1233                extension = %extension_id,
1234                frame = %value,
1235                "Unrecognized frame; dropping",
1236            );
1237        }
1238    }
1239
1240    pub fn restart_count(&self) -> usize {
1241        // Lifetime total — reporting/tests expect total restarts, not the
1242        // consecutive-failure budget counter (which resets on success).
1243        self.total_restarts.load(Ordering::Relaxed)
1244    }
1245
1246    /// Public for tests: set the permission set used by inbound RPC handlers
1247    /// (e.g. memory.append). Called by the manager after manifest validation.
1248    pub async fn set_permissions(&self, perms: crate::extensions::permissions::PermissionSet) {
1249        *self.inbox.permissions.write().await = Some(perms);
1250    }
1251
1252    /// Handle a JSON-RPC request initiated by the extension.
1253    ///
1254    /// Returns `Ok(result_value)` on success or `Err((code, message))` for a
1255    /// JSON-RPC error response. Currently routes:
1256    /// - `memory.append` (requires `memory.write`)
1257    /// - `memory.query`  (requires `memory.read`)
1258    ///   All other methods return -32601 (method not found).
1259    #[allow(clippy::doc_lazy_continuation)]
1260    async fn handle_inbound_request(
1261        inbox: &Arc<Inbox>,
1262        method: &str,
1263        params: Value,
1264    ) -> Result<Value, (i32, String)> {
1265        use crate::extensions::permissions::Permission;
1266        use crate::memory::store::{self, MemoryQuery};
1267
1268        if inbox.exclusive_memory && matches!(method, "memory.append" | "memory.query") {
1269            return Err((
1270                -32000,
1271                "legacy extension memory is disabled by the host-selected backend".into(),
1272            ));
1273        }
1274
1275        match method {
1276            "memory.append" => {
1277                Self::require_permission(inbox, Permission::MemoryWrite, "memory.write").await?;
1278                let namespace = Self::param_str(&params, "namespace")?;
1279                Self::require_namespace_matches(inbox, &namespace).await?;
1280                let content = Self::param_str(&params, "content")?;
1281                let tags = match params.get("tags") {
1282                    None | Some(Value::Null) => Vec::new(),
1283                    Some(Value::Array(arr)) => {
1284                        let mut out = Vec::with_capacity(arr.len());
1285                        for v in arr {
1286                            match v.as_str() {
1287                                Some(s) => out.push(s.to_string()),
1288                                None => {
1289                                    return Err((
1290                                        -32602,
1291                                        "tags must be an array of strings".to_string(),
1292                                    ))
1293                                }
1294                            }
1295                        }
1296                        out
1297                    }
1298                    _ => return Err((-32602, "tags must be an array of strings".to_string())),
1299                };
1300                let meta = match params.get("meta") {
1301                    None | Some(Value::Null) => None,
1302                    Some(v) => Some(v.clone()),
1303                };
1304                let record = store::new_record(namespace, content, tags, meta);
1305                let timestamp_ms = record.timestamp_ms;
1306                store::append(&record).map_err(|e| (-32000, e.to_string()))?;
1307                Ok(serde_json::json!({"ok": true, "timestamp_ms": timestamp_ms}))
1308            }
1309            "memory.query" => {
1310                Self::require_permission(inbox, Permission::MemoryRead, "memory.read").await?;
1311                let namespace = Self::param_str(&params, "namespace")?;
1312                Self::require_namespace_matches(inbox, &namespace).await?;
1313                let q = MemoryQuery {
1314                    content_contains: params
1315                        .get("content_contains")
1316                        .and_then(Value::as_str)
1317                        .map(str::to_string),
1318                    tag_prefix: params
1319                        .get("tag_prefix")
1320                        .and_then(Value::as_str)
1321                        .map(str::to_string),
1322                    since_ms: params.get("since_ms").and_then(Value::as_u64),
1323                    until_ms: params.get("until_ms").and_then(Value::as_u64),
1324                    limit: params
1325                        .get("limit")
1326                        .and_then(Value::as_u64)
1327                        .map(|n| n as usize),
1328                };
1329                let records = store::query(&namespace, &q).map_err(|e| (-32000, e.to_string()))?;
1330                Ok(serde_json::json!({"records": records}))
1331            }
1332            "config.get" => {
1333                let key = Self::param_str(&params, "key")?;
1334                Self::validate_config_key(&key)?;
1335                let value =
1336                    crate::extensions::config_store::read_plugin_config(&inbox.extension_id, &key);
1337                Ok(serde_json::json!({"value": value}))
1338            }
1339            "config.set" => {
1340                Self::require_permission(inbox, Permission::ConfigWrite, "config.write").await?;
1341                let key = Self::param_str(&params, "key")?;
1342                Self::validate_config_key(&key)?;
1343                let value = Self::param_str(&params, "value")?;
1344                crate::extensions::config_store::write_plugin_config(
1345                    &inbox.extension_id,
1346                    &key,
1347                    &value,
1348                )
1349                .map_err(|e| (-32000, e.to_string()))?;
1350                Ok(serde_json::json!({"ok": true}))
1351            }
1352            "config.subscribe" => {
1353                Self::require_permission(inbox, Permission::ConfigSubscribe, "config.subscribe")
1354                    .await?;
1355                // The long-lived watcher-to-notification bridge is wired at the manager/UI layer.
1356                // This phase exposes the authorized protocol ACK so plugins can opt in without
1357                // blocking initialize; direct store watchers are unit-tested in config_store.
1358                Ok(serde_json::json!({"ok": true}))
1359            }
1360            other => Err((-32601, format!("method not found: {other}"))),
1361        }
1362    }
1363
1364    async fn require_permission(
1365        inbox: &Arc<Inbox>,
1366        perm: crate::extensions::permissions::Permission,
1367        wire: &str,
1368    ) -> Result<(), (i32, String)> {
1369        let guard = inbox.permissions.read().await;
1370        match guard.as_ref() {
1371            Some(set) if set.has(perm) => Ok(()),
1372            _ => Err((-32602, format!("permission denied: {wire} required"))),
1373        }
1374    }
1375
1376    async fn require_namespace_matches(
1377        inbox: &Arc<Inbox>,
1378        namespace: &str,
1379    ) -> Result<(), (i32, String)> {
1380        if namespace == inbox.extension_id {
1381            Ok(())
1382        } else {
1383            Err((
1384                -32602,
1385                format!(
1386                    "namespace must equal extension id '{}' (got '{}')",
1387                    inbox.extension_id, namespace
1388                ),
1389            ))
1390        }
1391    }
1392
1393    fn param_str(params: &Value, name: &str) -> Result<String, (i32, String)> {
1394        params
1395            .get(name)
1396            .and_then(Value::as_str)
1397            .map(str::to_string)
1398            .ok_or_else(|| (-32602, format!("missing or invalid '{name}' parameter")))
1399    }
1400
1401    fn validate_config_key(key: &str) -> Result<(), (i32, String)> {
1402        let trimmed = key.trim();
1403        if trimmed.is_empty() {
1404            return Err((-32602, "config key must be non-empty".to_string()));
1405        }
1406        if trimmed.contains('.') || trimmed.contains('/') || trimmed.contains(' ') {
1407            return Err((
1408                -32602,
1409                "config key must not contain dots, slashes, or spaces".to_string(),
1410            ));
1411        }
1412        Ok(())
1413    }
1414
1415    pub async fn initialize(
1416        &self,
1417        plugin_root: Option<PathBuf>,
1418        config: Value,
1419    ) -> Result<InitializeCapabilitiesResult, String> {
1420        // Retain only in memory: restart handshakes must receive the same
1421        // resolved config, while secrets never enter env, logs, or storage.
1422        *self.initialize_config.lock().await = config.clone();
1423        let params = InitializeParams {
1424            synaps_version: env!("CARGO_PKG_VERSION"),
1425            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1426            plugin_id: self.id.clone(),
1427            plugin_root: plugin_root
1428                .or_else(|| self.cwd.clone())
1429                .map(|path| path.to_string_lossy().to_string()),
1430            config,
1431        };
1432        let value = self
1433            .call_no_restart(
1434                "initialize",
1435                serde_json::to_value(params).map_err(|e| e.to_string())?,
1436            )
1437            .await?;
1438        Self::parse_initialize_result(&self.id, value)
1439    }
1440
1441    fn parse_initialize_result(
1442        id: &str,
1443        value: Value,
1444    ) -> Result<InitializeCapabilitiesResult, String> {
1445        let result: InitializeResult = serde_json::from_value(value)
1446            .map_err(|e| format!("Invalid initialize response from extension '{}': {}", id, e))?;
1447        if result.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
1448            return Err(format!(
1449                "Extension '{}' initialize returned unsupported protocol_version {} (supported: {})",
1450                id, result.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
1451            ));
1452        }
1453        Self::validate_registered_tool_specs(id, &result.capabilities.tools)?;
1454        Self::validate_registered_provider_specs(id, &result.capabilities.providers)?;
1455        Ok(InitializeCapabilitiesResult {
1456            tools: result.capabilities.tools,
1457            providers: result.capabilities.providers,
1458            capabilities: result.capabilities.capabilities,
1459        })
1460    }
1461
1462    fn validate_registered_tool_specs(
1463        id: &str,
1464        tools: &[RegisteredExtensionToolSpec],
1465    ) -> Result<(), String> {
1466        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1467        let mut names = HashSet::new();
1468        for tool in tools {
1469            let name = tool.name.trim();
1470            if let Err(err) = validate_id_segment(name) {
1471                return Err(match err {
1472                    IdValidationError::Empty => format!(
1473                        "Extension '{}' registered a tool with an empty tool name",
1474                        id
1475                    ),
1476                    IdValidationError::ContainsReserved { ch } => format!(
1477                        "Extension '{}' registered tool '{}' with invalid tool name: '{}' is reserved",
1478                        id, name, ch
1479                    ),
1480                    IdValidationError::TooLong { len, max } => format!(
1481                        "Extension '{}' registered tool '{}' with invalid tool name: must be at most {} chars (got {})",
1482                        id, name, max, len
1483                    ),
1484                    IdValidationError::ContainsWhitespace => format!(
1485                        "Extension '{}' registered tool '{}' with invalid tool name: must not contain whitespace",
1486                        id, name
1487                    ),
1488                    IdValidationError::ContainsControl { ch } => format!(
1489                        "Extension '{}' registered tool '{}' with invalid tool name: contains control character U+{:04X}",
1490                        id, name, ch as u32
1491                    ),
1492                });
1493            }
1494            if !names.insert(name.to_string()) {
1495                return Err(format!(
1496                    "Extension '{}' registered duplicate tool name '{}'",
1497                    id, name
1498                ));
1499            }
1500            if tool.description.trim().is_empty() {
1501                return Err(format!(
1502                    "Extension '{}' registered tool '{}' with an empty description",
1503                    id, name,
1504                ));
1505            }
1506            if !tool.input_schema.is_object() {
1507                return Err(format!(
1508                    "Extension '{}' registered tool '{}' with invalid input_schema: input_schema must be a JSON object",
1509                    id, name,
1510                ));
1511            }
1512        }
1513        Ok(())
1514    }
1515
1516    fn validate_registered_provider_specs(
1517        id: &str,
1518        providers: &[RegisteredProviderSpec],
1519    ) -> Result<(), String> {
1520        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1521        for provider in providers {
1522            let provider_id = provider.id.trim();
1523            match validate_id_segment(provider_id) {
1524                Ok(()) => {
1525                    if !Self::is_safe_provider_id(provider_id) {
1526                        return Err(format!(
1527                            "Extension '{}' registered provider '{}' with invalid provider id",
1528                            id, provider_id
1529                        ));
1530                    }
1531                }
1532                Err(IdValidationError::Empty) => {
1533                    return Err(format!(
1534                        "Extension '{}' registered provider with empty provider id",
1535                        id
1536                    ));
1537                }
1538                Err(err) => {
1539                    return Err(format!(
1540                        "Extension '{}' registered provider '{}' with invalid provider id: {}",
1541                        id, provider_id, err
1542                    ));
1543                }
1544            }
1545            if provider.display_name.trim().is_empty() {
1546                return Err(format!(
1547                    "Extension '{}' registered provider '{}' with empty display_name",
1548                    id, provider_id,
1549                ));
1550            }
1551            if provider.description.trim().is_empty() {
1552                return Err(format!(
1553                    "Extension '{}' registered provider '{}' with empty description",
1554                    id, provider_id,
1555                ));
1556            }
1557            if provider.models.is_empty() {
1558                return Err(format!(
1559                    "Extension '{}' registered provider '{}' must declare at least one model",
1560                    id, provider_id,
1561                ));
1562            }
1563            let mut model_ids = HashSet::new();
1564            for model in &provider.models {
1565                let model_id = model.id.trim();
1566                if let Err(err) = validate_id_segment(model_id) {
1567                    return Err(match err {
1568                        IdValidationError::Empty => format!(
1569                            "Extension '{}' registered provider '{}' with empty model id",
1570                            id, provider_id
1571                        ),
1572                        IdValidationError::ContainsReserved { ch } => format!(
1573                            "Extension '{}' registered provider '{}' with invalid model id '{}': '{}' is reserved",
1574                            id, provider_id, model_id, ch
1575                        ),
1576                        IdValidationError::TooLong { len, max } => format!(
1577                            "Extension '{}' registered provider '{}' with invalid model id '{}': must be at most {} chars (got {})",
1578                            id, provider_id, model_id, max, len
1579                        ),
1580                        IdValidationError::ContainsWhitespace => format!(
1581                            "Extension '{}' registered provider '{}' with invalid model id '{}': must not contain whitespace",
1582                            id, provider_id, model_id
1583                        ),
1584                        IdValidationError::ContainsControl { ch } => format!(
1585                            "Extension '{}' registered provider '{}' with invalid model id '{}': contains control character U+{:04X}",
1586                            id, provider_id, model_id, ch as u32
1587                        ),
1588                    });
1589                }
1590                if !model_ids.insert(model_id.to_string()) {
1591                    return Err(format!(
1592                        "Extension '{}' registered provider '{}' with duplicate model id '{}'",
1593                        id, provider_id, model_id,
1594                    ));
1595                }
1596            }
1597            if let Some(config_schema) = &provider.config_schema {
1598                if !config_schema.is_object() {
1599                    return Err(format!(
1600                        "Extension '{}' registered provider '{}' with invalid config_schema: config_schema must be a JSON object",
1601                        id, provider_id,
1602                    ));
1603                }
1604            }
1605        }
1606        Ok(())
1607    }
1608
1609    fn is_safe_provider_id(id: &str) -> bool {
1610        !id.is_empty()
1611            && !id.contains(':')
1612            && id
1613                .chars()
1614                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1615    }
1616
1617    #[doc(hidden)]
1618    pub async fn initialize_for_test(&self, plugin_root: Option<PathBuf>) -> Result<(), String> {
1619        self.initialize(plugin_root, Value::Object(Default::default()))
1620            .await
1621            .map(|_| ())
1622    }
1623
1624    async fn restart_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1625        if self.stopped.load(Ordering::Acquire) {
1626            return Err(format!(
1627                "Extension '{}' was stopped (unloaded or reloaded); not respawning it",
1628                self.id,
1629            ));
1630        }
1631        self.inbox.invalidate_lifecycle().await;
1632        let attempted = self.restart_count.fetch_add(1, Ordering::Relaxed) + 1;
1633        self.total_restarts.fetch_add(1, Ordering::Relaxed);
1634        let max_attempts = self.restart_policy.max_attempts;
1635        if attempted > max_attempts as usize {
1636            *state = None;
1637            return Err(format!(
1638                "Extension '{}' exceeded restart limit ({})",
1639                self.id, max_attempts,
1640            ));
1641        }
1642
1643        if let Some(old) = state.take() {
1644            old.reader_handle.abort();
1645            old.lifecycle.invalidate();
1646            let _ = old.lifecycle.child.lock().await.kill().await;
1647        }
1648        // Drain any stale pending entries before reusing the inbox.
1649        self.inbox
1650            .fail_all_pending("transport closed: process restarting")
1651            .await;
1652
1653        let delay = self
1654            .restart_policy
1655            .delay_for_attempt(attempted as u32)
1656            .unwrap_or_default();
1657
1658        tracing::warn!(
1659            extension = %self.id,
1660            attempt = attempted,
1661            max_attempts = max_attempts,
1662            delay_ms = delay.as_millis() as u64,
1663            "Restarting extension process after transport failure",
1664        );
1665
1666        if !delay.is_zero() {
1667            tokio::time::sleep(delay).await;
1668        }
1669
1670        *state = Some(
1671            Self::spawn_state(
1672                &self.id,
1673                &self.command,
1674                &self.args,
1675                self.cwd.as_ref(),
1676                self.inbox.clone(),
1677            )
1678            .await?,
1679        );
1680        self.initialize_locked(state).await?;
1681        // NOTE: the consecutive-failure counter is NOT reset here. A
1682        // successful handshake isn't proof of recovery — a crash-looping
1683        // extension can initialize fine and die on the next request
1684        // (always_exit fixture). The reset happens in call_inner once the
1685        // restarted process actually serves a call (preserves H-8 intent:
1686        // transient failures hours apart don't accumulate toward disable).
1687        Ok(())
1688    }
1689
1690    async fn initialize_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1691        let config = self.initialize_config.lock().await.clone();
1692        let params = InitializeParams {
1693            synaps_version: env!("CARGO_PKG_VERSION"),
1694            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1695            plugin_id: self.id.clone(),
1696            plugin_root: self
1697                .cwd
1698                .clone()
1699                .map(|path| path.to_string_lossy().to_string()),
1700            config,
1701        };
1702        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1703        let value = tokio::time::timeout(
1704            std::time::Duration::from_secs(10),
1705            self.call_once_locked(
1706                state.as_mut().expect("state should exist for initialize"),
1707                "initialize",
1708                serde_json::to_value(params).map_err(|e| e.to_string())?,
1709                id,
1710            ),
1711        )
1712        .await
1713        .map_err(|_| format!("Extension '{}' initialize timed out after 10s", self.id))??;
1714        let result = Self::parse_initialize_result(&self.id, value).map(|_| ());
1715        if let Some(state) = state.as_ref() {
1716            if result.is_ok() {
1717                state.lifecycle.initialized.store(true, Ordering::Release);
1718            } else {
1719                state.lifecycle.invalidate();
1720            }
1721        }
1722        result
1723    }
1724
1725    /// Send a single JSON-RPC request and await the matching response,
1726    /// using the shared inbox for response delivery. The reader task runs
1727    /// concurrently and may dispatch interleaved notifications.
1728    async fn call_once_locked(
1729        &self,
1730        state: &mut ProcessState,
1731        method: &str,
1732        params: Value,
1733        id: u64,
1734    ) -> Result<Value, String> {
1735        let body = serde_json::to_string(&JsonRpcRequest {
1736            jsonrpc: "2.0",
1737            method: method.to_string(),
1738            params,
1739            id,
1740        })
1741        .map_err(|e| format!("Serialize error: {}", e))?;
1742
1743        let (tx, rx) = oneshot::channel::<Result<Value, String>>();
1744        // Check if inbox is closed before registering — if the reader already
1745        // exited, no one will ever send a response on this channel.
1746        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1747            return Err("transport closed: inbox is shut down".to_string());
1748        }
1749
1750        // Register pending BEFORE writing so the reader can route a fast
1751        // response without racing against the insert.
1752        self.inbox.pending.lock().await.insert(id, tx);
1753
1754        // Double-check: if closed was set between our check and the insert,
1755        // remove the entry and fail immediately.
1756        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1757            self.inbox.pending.lock().await.remove(&id);
1758            return Err("transport closed: inbox shut down during registration".to_string());
1759        }
1760
1761        let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
1762        let write_result = {
1763            let mut stdin = state.stdin.lock().await;
1764            match stdin.write_all(frame.as_bytes()).await {
1765                Ok(()) => stdin.flush().await,
1766                Err(e) => Err(e),
1767            }
1768        };
1769        if let Err(e) = write_result {
1770            state.lifecycle.invalidate();
1771            // Make sure we don't leak the pending entry if the write fails.
1772            self.inbox.pending.lock().await.remove(&id);
1773            return Err(format!("Write error: {}", e));
1774        }
1775
1776        match rx.await {
1777            Ok(payload) => payload,
1778            Err(_) => {
1779                state.lifecycle.invalidate();
1780                // Sender was dropped without sending — typically because the
1781                // reader task observed EOF/error after we registered. The
1782                // reader normally sends an Err first; this branch is a
1783                // belt-and-braces fallback.
1784                self.inbox.pending.lock().await.remove(&id);
1785                Err("transport closed: response channel dropped".to_string())
1786            }
1787        }
1788    }
1789
1790    async fn call_no_restart(&self, method: &str, params: Value) -> Result<Value, String> {
1791        let _call_guard = self.call_lock.lock().await;
1792        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1793        let mut state_guard = self.state.lock().await;
1794        if state_guard.is_none() {
1795            *state_guard = Some(
1796                Self::spawn_state(
1797                    &self.id,
1798                    &self.command,
1799                    &self.args,
1800                    self.cwd.as_ref(),
1801                    self.inbox.clone(),
1802                )
1803                .await?,
1804            );
1805        }
1806        let state = state_guard.as_mut().expect("state should exist");
1807        let result = self.call_once_locked(state, method, params, id).await;
1808        if method == "initialize" {
1809            // Publish readiness while still serialized with replacement; never
1810            // accidentally mark a newer child ready using an older response.
1811            match result.as_ref() {
1812                Ok(value) if Self::parse_initialize_result(&self.id, value.clone()).is_ok() => {
1813                    state.lifecycle.initialized.store(true, Ordering::Release);
1814                }
1815                _ => state.lifecycle.invalidate(),
1816            }
1817        }
1818        result
1819    }
1820
1821    /// Send one request only if the current child is still present. Teardown
1822    /// must never resurrect a child that concurrent cancellation already
1823    /// removed, so unlike [`Self::call`] this bypasses all spawn/restart paths.
1824    async fn call_no_spawn(&self, method: &str, params: Value) -> Result<Value, String> {
1825        let _call_guard = self.call_lock.lock().await;
1826        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1827        let mut state_guard = self.state.lock().await;
1828        let Some(state) = state_guard.as_mut() else {
1829            return Err(format!(
1830                "Extension '{}' process is not running; '{}' refused (no respawn)",
1831                self.id, method
1832            ));
1833        };
1834        self.call_once_locked(state, method, params, id).await
1835    }
1836
1837    /// Task 20 lease path: one `tool.call` that NEVER (re)spawns. A missing
1838    /// process state fails typed instead of resurrecting a terminated
1839    /// child, and the restart machinery is bypassed entirely — a lease
1840    /// child that dies is a transport error, never a silent new process
1841    /// whose declarations were not re-validated.
1842    pub(crate) async fn call_tool_once_no_spawn(
1843        &self,
1844        name: &str,
1845        input: Value,
1846        session_id: Option<&str>,
1847    ) -> Result<Value, String> {
1848        let fut = async {
1849            let _call_guard = self.call_lock.lock().await;
1850            let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1851            // Take the running state out while awaiting the response so a
1852            // concurrent cancellation can observe an empty slot and still
1853            // fail the shared inbox pending map. The state is restored only
1854            // if cancellation did not close the inbox.
1855            let mut state = {
1856                let mut state_guard = self.state.lock().await;
1857                state_guard.take().ok_or_else(|| {
1858                    format!(
1859                        "Extension '{}' process is not running; leased call refused (no respawn)",
1860                        self.id
1861                    )
1862                })?
1863            };
1864            let result = self
1865                .call_once_locked(
1866                    &mut state,
1867                    "tool.call",
1868                    tool_call_params(name, input, session_id),
1869                    id,
1870                )
1871                .await;
1872            if !self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1873                *self.state.lock().await = Some(state);
1874            } else {
1875                state.reader_handle.abort();
1876                state.lifecycle.invalidate();
1877                let _ = state.lifecycle.child.lock().await.kill().await;
1878            }
1879            result
1880        };
1881        match tokio::time::timeout(std::time::Duration::from_secs(120), fut).await {
1882            Ok(result) => result,
1883            Err(_) => Err(format!(
1884                "Extension '{}' leased tool.call timed out after 120s",
1885                self.id
1886            )),
1887        }
1888    }
1889
1890    async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
1891        let timeout_secs = if method == "tool.call" { 120 } else { 30 };
1892        let id_str = self.id.clone();
1893        let method_str = method.to_string();
1894
1895        let result = tokio::time::timeout(
1896            std::time::Duration::from_secs(timeout_secs),
1897            self.call_inner(method, params),
1898        )
1899        .await;
1900
1901        match result {
1902            Ok(inner) => inner,
1903            Err(_) => Err(format!(
1904                "Extension '{}' method '{}' timed out after {}s",
1905                id_str, method_str, timeout_secs
1906            )),
1907        }
1908    }
1909
1910    async fn call_inner(&self, method: &str, params: Value) -> Result<Value, String> {
1911        let _call_guard = self.call_lock.lock().await;
1912        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1913        let mut state_guard = self.state.lock().await;
1914        if state_guard.is_none() {
1915            self.restart_locked(&mut state_guard).await?;
1916        }
1917
1918        let result = self
1919            .call_once_locked(
1920                state_guard
1921                    .as_mut()
1922                    .expect("state should exist after restart"),
1923                method,
1924                params.clone(),
1925                id,
1926            )
1927            .await;
1928
1929        match result {
1930            Ok(value) => {
1931                // Process served a real call — it has genuinely recovered.
1932                // Reset the consecutive-failure budget (H-8) so transient
1933                // crashes hours apart don't accumulate toward disable.
1934                self.restart_count.store(0, Ordering::Relaxed);
1935                Ok(value)
1936            }
1937            Err(first_error) if is_application_error(&first_error) => {
1938                // The process answered with a JSON-RPC `error` object
1939                // (unknown method, invalid params, a tool refusing its
1940                // input). That is a live, healthy transport — restarting
1941                // would only re-spawn the process to hear the same answer
1942                // again, so it also counts as a real recovery.
1943                self.restart_count.store(0, Ordering::Relaxed);
1944                Err(first_error)
1945            }
1946            Err(first_error) => {
1947                self.restart_locked(&mut state_guard).await?;
1948                let retry_id = self.next_id.fetch_add(1, Ordering::Relaxed);
1949                self.call_once_locked(
1950                    state_guard
1951                        .as_mut()
1952                        .expect("state should exist after restart"),
1953                    method,
1954                    params,
1955                    retry_id,
1956                )
1957                .await
1958                .inspect(|_| {
1959                    // Retry succeeded post-restart — also a real recovery.
1960                    self.restart_count.store(0, Ordering::Relaxed);
1961                })
1962                .map_err(|retry_error| {
1963                    format!(
1964                        "{}; retry after restart failed: {}",
1965                        first_error, retry_error
1966                    )
1967                })
1968            }
1969        }
1970    }
1971
1972    /// Subscribe to JSON-RPC notifications emitted by the extension.
1973    ///
1974    /// Returns `(subscription_id, receiver)`. The id must be passed back to
1975    /// [`unsubscribe_notifications`] to cancel this subscription specifically
1976    /// — concurrent subscribers (e.g. background widget watchers running
1977    /// alongside `command.invoke` / `provider.stream`) coexist and each
1978    /// receives every notification frame.
1979    ///
1980    /// The receiver yields every notification frame (no `id`, has `method`)
1981    /// the extension sends until either:
1982    /// - the receiver is dropped (its slot is pruned on the next dispatch),
1983    /// - [`unsubscribe_notifications`] is called with this id, or
1984    /// - the reader observes EOF or a transport error (all subscribers are
1985    ///   dropped together).
1986    ///
1987    /// Internal API: exposed publicly with `#[doc(hidden)]` only so
1988    /// integration tests can exercise the bidirectional transport.
1989    #[doc(hidden)]
1990    pub async fn subscribe_notifications(&self) -> (usize, mpsc::Receiver<NotificationFrame>) {
1991        let (tx, rx) = mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
1992        let id = self
1993            .inbox
1994            .next_sink_id
1995            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1996        let mut sinks = self.inbox.notification_sinks.lock().await;
1997        // Opportunistically prune any subscribers whose receivers have been
1998        // dropped, so the Vec doesn't grow unboundedly across long-lived
1999        // extensions if callers forget to unsubscribe.
2000        sinks.retain(|(_, tx)| !tx.is_closed());
2001        sinks.push((id, tx));
2002        (id, rx)
2003    }
2004
2005    /// Drop the notification subscription with the given id, if any.
2006    /// Unknown ids are silently ignored (idempotent — safe to call on
2007    /// belt-and-braces timeout paths even after the inner future already
2008    /// unsubscribed).
2009    #[doc(hidden)]
2010    pub async fn unsubscribe_notifications(&self, id: usize) {
2011        let mut sinks = self.inbox.notification_sinks.lock().await;
2012        sinks.retain(|(sub_id, tx)| *sub_id != id && !tx.is_closed());
2013    }
2014
2015    /// Forward one notification frame received during `command.invoke`.
2016    ///
2017    /// Routes:
2018    /// - `command.output` whose `request_id` matches → sink as `Output(event)`
2019    ///   (other request_ids are ignored — concurrent invocations must use distinct ids
2020    ///   and may overlap in the same `subscribe_notifications` channel; mismatched
2021    ///   frames are dropped here intentionally).
2022    /// - `task.start|update|log|done` → sink as `Task(event)` regardless of request_id.
2023    /// - Anything else → logged at trace and dropped.
2024    ///
2025    /// CP-11 fix-3: the sink is BOUNDED
2026    /// ([`crate::extensions::invoke_output::InvokeEventSink`]) and its
2027    /// send is AWAITED — a hostile `command.output` flood backpressures
2028    /// this loop (and transitively the bounded notification queue and the
2029    /// sidecar's stdout pipe) instead of parking aggregate bytes in an
2030    /// unbounded queue. Liveness relies on the eagerly concurrent
2031    /// collector on the other end (see `invoke_output` module docs); if
2032    /// that consumer is gone, the send errs and `sink_open` latches false.
2033    pub(crate) async fn forward_invoke_command_frame(
2034        extension_id: &str,
2035        request_id: &str,
2036        sink: &crate::extensions::invoke_output::InvokeEventSink,
2037        sink_open: &mut bool,
2038        frame: NotificationFrame,
2039    ) -> bool {
2040        use crate::extensions::commands::parse_command_output;
2041        use crate::extensions::runtime::InvokeCommandEvent;
2042        use crate::extensions::tasks::{is_task_method, parse_task_event};
2043
2044        let mut saw_done = false;
2045        if frame.method == "command.output" {
2046            match parse_command_output(&frame.params) {
2047                Ok(parsed) if parsed.request_id == request_id => {
2048                    if matches!(
2049                        parsed.event,
2050                        crate::extensions::commands::CommandOutputEvent::Done
2051                    ) {
2052                        saw_done = true;
2053                    }
2054                    if *sink_open
2055                        && sink
2056                            .send(InvokeCommandEvent::Output(parsed.event))
2057                            .await
2058                            .is_err()
2059                    {
2060                        *sink_open = false;
2061                    }
2062                }
2063                Ok(_) => {
2064                    // Different request_id — ignore (logged at trace for diagnostics).
2065                    tracing::trace!(
2066                        extension = %extension_id,
2067                        "Ignoring command.output for unrelated request_id",
2068                    );
2069                }
2070                Err(error) => {
2071                    tracing::warn!(
2072                        extension = %extension_id,
2073                        error = %error,
2074                        params = %frame.params,
2075                        "Skipping malformed command.output notification",
2076                    );
2077                }
2078            }
2079        } else if is_task_method(&frame.method) {
2080            match parse_task_event(&frame.method, &frame.params) {
2081                Ok(event) => {
2082                    if *sink_open && sink.send(InvokeCommandEvent::Task(event)).await.is_err() {
2083                        *sink_open = false;
2084                    }
2085                }
2086                Err(error) => {
2087                    tracing::warn!(
2088                        extension = %extension_id,
2089                        method = %frame.method,
2090                        error = %error,
2091                        params = %frame.params,
2092                        "Skipping malformed task notification",
2093                    );
2094                }
2095            }
2096        } else {
2097            tracing::trace!(
2098                extension = %extension_id,
2099                method = %frame.method,
2100                "Ignoring non-command/task notification during command.invoke",
2101            );
2102        }
2103        saw_done
2104    }
2105
2106    /// Forward one notification frame received during `provider.stream`.
2107    ///
2108    /// - Frames whose method is not `provider.stream.event` are ignored (logged at trace).
2109    /// - Malformed event params are logged at warn and skipped (do not abort the call).
2110    /// - The sink is BOUNDED ([`PROVIDER_EVENT_QUEUE_CAPACITY`]): a slow
2111    ///   downstream backpressures this loop (and transitively the sidecar)
2112    ///   instead of retaining hostile flood events in host memory.
2113    /// - If the caller's sink has been closed, sets `sink_open = false` and stops forwarding,
2114    ///   but the in-flight request is still allowed to complete.
2115    async fn forward_provider_stream_frame(
2116        extension_id: &str,
2117        sink: &mpsc::Sender<ProviderStreamEvent>,
2118        sink_open: &mut bool,
2119        frame: NotificationFrame,
2120    ) {
2121        if frame.method != "provider.stream.event" {
2122            tracing::trace!(
2123                extension = %extension_id,
2124                method = %frame.method,
2125                "Ignoring non-stream notification during provider.stream",
2126            );
2127            return;
2128        }
2129        match parse_provider_stream_event(&frame.params) {
2130            Ok(event) => {
2131                if *sink_open && sink.send(event).await.is_err() {
2132                    *sink_open = false;
2133                }
2134            }
2135            Err(error) => {
2136                tracing::warn!(
2137                    extension = %extension_id,
2138                    error = %error,
2139                    params = %frame.params,
2140                    "Skipping malformed provider.stream.event notification",
2141                );
2142            }
2143        }
2144    }
2145}
2146
2147/// `tool.call` params. `session_id` is the owning conversation id, `null`
2148/// for workers/no conversation and under the `SYNAPS_HOOK_SESSION_ID=0`
2149/// kill switch, so plugins can key tool state on the same value the hook
2150/// events carry. Always present (`null`, never omitted), like hook fields.
2151pub(crate) fn tool_call_params(name: &str, input: Value, session_id: Option<&str>) -> Value {
2152    tool_call_params_gated(
2153        name,
2154        input,
2155        session_id,
2156        crate::extensions::hooks::events::hook_session_id_enabled(),
2157    )
2158}
2159
2160fn tool_call_params_gated(
2161    name: &str,
2162    input: Value,
2163    session_id: Option<&str>,
2164    session_id_enabled: bool,
2165) -> Value {
2166    let session_id = if session_id_enabled { session_id } else { None };
2167    serde_json::json!({
2168        "name": name,
2169        "input": input,
2170        "session_id": session_id,
2171    })
2172}
2173
2174#[async_trait]
2175impl ExtensionHandler for ProcessExtension {
2176    fn id(&self) -> &str {
2177        &self.id
2178    }
2179
2180    fn lifecycle_snapshot(&self) -> Option<ExtensionLifecycle> {
2181        let lifecycle = self.inbox.lifecycle.try_lock().ok()?.clone()?;
2182        lifecycle.snapshot(self.total_restarts.load(Ordering::Relaxed) > 0)
2183    }
2184
2185    async fn call_tool(&self, name: &str, input: Value) -> Result<Value, String> {
2186        self.call_tool_for(name, input, None).await
2187    }
2188
2189    async fn call_tool_for(
2190        &self,
2191        name: &str,
2192        input: Value,
2193        session_id: Option<&str>,
2194    ) -> Result<Value, String> {
2195        self.call("tool.call", tool_call_params(name, input, session_id))
2196            .await
2197    }
2198
2199    async fn provider_complete(
2200        &self,
2201        params: ProviderCompleteParams,
2202    ) -> Result<ProviderCompleteResult, String> {
2203        let value = tokio::time::timeout(
2204            std::time::Duration::from_secs(60),
2205            self.call(
2206                "provider.complete",
2207                serde_json::to_value(params).map_err(|e| e.to_string())?,
2208            ),
2209        )
2210        .await
2211        .map_err(|_| format!("Extension '{}' provider.complete timed out", self.id))??;
2212        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
2213            format!(
2214                "Invalid provider.complete response from extension '{}': {}",
2215                self.id, e
2216            )
2217        })?;
2218        if result.content.is_empty() {
2219            return Err(format!(
2220                "Extension '{}' provider.complete returned empty content",
2221                self.id
2222            ));
2223        }
2224        Ok(result)
2225    }
2226
2227    async fn provider_stream(
2228        &self,
2229        params: ProviderCompleteParams,
2230        sink: tokio::sync::mpsc::Sender<ProviderStreamEvent>,
2231    ) -> Result<ProviderCompleteResult, String> {
2232        // Subscribe BEFORE issuing the request so we don't miss early
2233        // notifications that may arrive before `call(...)` even starts polling.
2234        let (sub_id, mut rx) = self.subscribe_notifications().await;
2235        let params_value = serde_json::to_value(params).map_err(|e| e.to_string())?;
2236
2237        let extension_id = self.id.clone();
2238        let stream_future = async {
2239            let mut call_fut = Box::pin(self.call("provider.stream", params_value));
2240            let mut sink_open = true;
2241            let response = loop {
2242                tokio::select! {
2243                    response = &mut call_fut => break response,
2244                    Some(frame) = rx.recv() => {
2245                        Self::forward_provider_stream_frame(
2246                            &extension_id, &sink, &mut sink_open, frame,
2247                        ).await;
2248                    }
2249                }
2250            };
2251            // Response received: drop our subscription so this receiver yields
2252            // `None` once buffered frames are drained, then flush any
2253            // remaining notifications before returning. Other concurrent
2254            // subscribers (if any) are untouched.
2255            self.unsubscribe_notifications(sub_id).await;
2256            while let Some(frame) = rx.recv().await {
2257                Self::forward_provider_stream_frame(&extension_id, &sink, &mut sink_open, frame)
2258                    .await;
2259            }
2260            response
2261        };
2262
2263        let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), stream_future).await;
2264
2265        // Belt-and-braces: ensure our subscription is cleared on timeout too.
2266        // Idempotent if the inner future already unsubscribed.
2267        self.unsubscribe_notifications(sub_id).await;
2268
2269        let value =
2270            outcome.map_err(|_| format!("Extension '{}' provider.stream timed out", self.id))??;
2271
2272        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
2273            format!(
2274                "Invalid provider.stream response from extension '{}': {}",
2275                self.id, e
2276            )
2277        })?;
2278        // NOTE: empty `content` is permitted for streaming — output may have
2279        // been delivered entirely via TextDelta notifications.
2280        Ok(result)
2281    }
2282
2283    async fn invoke_command(
2284        &self,
2285        command: &str,
2286        args: Vec<String>,
2287        request_id: &str,
2288        sink: crate::extensions::invoke_output::InvokeEventSink,
2289    ) -> Result<Value, String> {
2290        // Subscribe before issuing the request so we don't miss early events.
2291        let (sub_id, mut rx) = self.subscribe_notifications().await;
2292        let params = serde_json::json!({
2293            "command": command,
2294            "args": args,
2295            "request_id": request_id,
2296        });
2297
2298        let extension_id = self.id.clone();
2299        let request_id_owned = request_id.to_string();
2300        let invoke_future = async {
2301            let mut call_fut = Box::pin(self.call("command.invoke", params));
2302            let mut sink_open = true;
2303            let response = loop {
2304                tokio::select! {
2305                    response = &mut call_fut => break response,
2306                    Some(frame) = rx.recv() => {
2307                        // The awaited bounded send paces a flood; while it
2308                        // waits, `call_fut` is simply not polled — safe
2309                        // because the eagerly concurrent collector always
2310                        // drains (see invoke_output module docs), and if it
2311                        // is gone the send errs immediately.
2312                        let _ = Self::forward_invoke_command_frame(
2313                            &extension_id, &request_id_owned, &sink, &mut sink_open, frame,
2314                        ).await;
2315                    }
2316                }
2317            };
2318            // Drain any notifications already buffered after the response lands, but
2319            // do not wait for the subscriber channel to close (that would deadlock
2320            // while this invocation still owns `rx`).
2321            self.unsubscribe_notifications(sub_id).await;
2322            while let Ok(frame) = rx.try_recv() {
2323                let _ = Self::forward_invoke_command_frame(
2324                    &extension_id,
2325                    &request_id_owned,
2326                    &sink,
2327                    &mut sink_open,
2328                    frame,
2329                )
2330                .await;
2331            }
2332            response
2333        };
2334
2335        let outcome =
2336            tokio::time::timeout(std::time::Duration::from_secs(120), invoke_future).await;
2337
2338        // Belt-and-braces: ensure our subscription is cleared on timeout too.
2339        // Idempotent if the inner future already unsubscribed. (On timeout
2340        // the dropped `invoke_future` also drops `sink` and `rx`, releasing
2341        // the collector and any dispatcher blocked on this subscription.)
2342        self.unsubscribe_notifications(sub_id).await;
2343
2344        outcome.map_err(|_| format!("Extension '{}' command.invoke timed out", self.id))?
2345    }
2346
2347    async fn handle(&self, event: &HookEvent) -> HookResult {
2348        let params = serde_json::to_value(event).unwrap_or(Value::Null);
2349        match tokio::time::timeout(
2350            std::time::Duration::from_secs(5),
2351            self.call("hook.handle", params),
2352        )
2353        .await
2354        {
2355            Ok(Ok(value)) => match serde_json::from_value(value.clone()) {
2356                Ok(result) => result,
2357                Err(error) => {
2358                    tracing::warn!(
2359                        extension = %self.id,
2360                        error = %error,
2361                        response = %value,
2362                        "Extension hook handler returned invalid result",
2363                    );
2364                    match value.get("action").and_then(Value::as_str) {
2365                        // A malformed `modify` is dangerous — it would let an
2366                        // unknown/partial input through. Fail safe by blocking.
2367                        Some("modify") => HookResult::Block {
2368                            reason: "Extension returned malformed modify result".to_string(),
2369                        },
2370                        // A malformed `replace` is safe to ignore: the original
2371                        // tool output is preserved by emit_after_tool_call.
2372                        // Explicit arm so this intent can't be silently changed
2373                        // by a future edit to the catch-all below.
2374                        Some("replace") => HookResult::Continue,
2375                        _ => HookResult::Continue,
2376                    }
2377                }
2378            },
2379            Ok(Err(e)) => {
2380                tracing::warn!(
2381                    extension = %self.id,
2382                    error = %e,
2383                    "Extension hook handler failed — continuing",
2384                );
2385                HookResult::Continue
2386            }
2387            Err(_) => {
2388                tracing::warn!(
2389                    extension = %self.id,
2390                    timeout_secs = 5,
2391                    "Extension hook handler timed out — continuing",
2392                );
2393                HookResult::Continue
2394            }
2395        }
2396    }
2397
2398    async fn get_info(&self) -> Result<crate::extensions::info::PluginInfo, String> {
2399        let value = tokio::time::timeout(
2400            std::time::Duration::from_secs(5),
2401            self.call("info.get", Value::Null),
2402        )
2403        .await
2404        .map_err(|_| format!("Extension '{}' info.get timed out", self.id))??;
2405        serde_json::from_value(value).map_err(|e| {
2406            format!(
2407                "Invalid info.get response from extension '{}': {}",
2408                self.id, e
2409            )
2410        })
2411    }
2412
2413    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
2414        let value = tokio::time::timeout(
2415            std::time::Duration::from_secs(5),
2416            self.call("sidecar.spawn_args", Value::Null),
2417        )
2418        .await
2419        .map_err(|_| format!("Extension '{}' sidecar.spawn_args timed out", self.id))??;
2420        serde_json::from_value(value).map_err(|e| {
2421            format!(
2422                "Invalid sidecar.spawn_args response from extension '{}': {}",
2423                self.id, e
2424            )
2425        })
2426    }
2427
2428    async fn settings_editor_open(&self, category: &str, field: &str) -> Result<Value, String> {
2429        let params = crate::extensions::settings_editor::SettingsEditorOpenParams {
2430            category: category.to_string(),
2431            field: field.to_string(),
2432        };
2433        tokio::time::timeout(
2434            std::time::Duration::from_secs(5),
2435            self.call(
2436                "settings.editor.open",
2437                serde_json::to_value(params).map_err(|e| e.to_string())?,
2438            ),
2439        )
2440        .await
2441        .map_err(|_| format!("Extension '{}' settings.editor.open timed out", self.id))?
2442    }
2443
2444    async fn settings_editor_key(
2445        &self,
2446        category: &str,
2447        field: &str,
2448        key: &str,
2449    ) -> Result<Value, String> {
2450        let mut params = serde_json::to_value(
2451            crate::extensions::settings_editor::SettingsEditorKeyParams {
2452                key: key.to_string(),
2453            },
2454        )
2455        .map_err(|e| e.to_string())?;
2456        if let Some(obj) = params.as_object_mut() {
2457            obj.insert("category".to_string(), Value::String(category.to_string()));
2458            obj.insert("field".to_string(), Value::String(field.to_string()));
2459        }
2460        tokio::time::timeout(
2461            std::time::Duration::from_secs(5),
2462            self.call("settings.editor.key", params),
2463        )
2464        .await
2465        .map_err(|_| format!("Extension '{}' settings.editor.key timed out", self.id))?
2466    }
2467
2468    async fn settings_editor_commit(
2469        &self,
2470        category: &str,
2471        field: &str,
2472        value: Value,
2473    ) -> Result<Value, String> {
2474        let params = serde_json::json!({
2475            "category": category,
2476            "field": field,
2477            "value": value,
2478        });
2479        tokio::time::timeout(
2480            std::time::Duration::from_secs(5),
2481            self.call("settings.editor.commit", params),
2482        )
2483        .await
2484        .map_err(|_| format!("Extension '{}' settings.editor.commit timed out", self.id))?
2485    }
2486
2487    async fn shutdown(&self) {
2488        // Terminal before anything else: a call already past authorization
2489        // must find this handler stopped, not respawn it (`restart_locked`).
2490        self.stopped.store(true, Ordering::Release);
2491        self.inbox.invalidate_lifecycle().await;
2492        // Lease teardown is terminal for this ProcessExtension. In
2493        // particular, a concurrent force_shutdown may already have removed
2494        // the child; sending shutdown through the normal restart-capable
2495        // call path would then spawn an unvalidated replacement solely to
2496        // shut it down.
2497        let _ = tokio::time::timeout(
2498            std::time::Duration::from_millis(500),
2499            self.call_no_spawn("shutdown", Value::Null),
2500        )
2501        .await;
2502
2503        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2504        let mut state_guard = self.state.lock().await;
2505        if let Some(state) = state_guard.take() {
2506            state.reader_handle.abort();
2507            state.lifecycle.invalidate();
2508            let _ = state.lifecycle.child.lock().await.kill().await;
2509        }
2510        // Drop all active notification subscribers and signal pending callers.
2511        self.inbox.notification_sinks.lock().await.clear();
2512        self.inbox
2513            .fail_all_pending("transport closed: extension shutdown")
2514            .await;
2515    }
2516
2517    async fn subscribe_notifications(
2518        &self,
2519    ) -> (usize, tokio::sync::mpsc::Receiver<NotificationFrame>) {
2520        ProcessExtension::subscribe_notifications(self).await
2521    }
2522
2523    async fn restart_count(&self) -> usize {
2524        self.restart_count()
2525    }
2526
2527    async fn health(&self) -> ExtensionHealth {
2528        // Exhaustion is judged on the consecutive-failure counter (resets on
2529        // successful restart); Degraded is judged on lifetime restarts so a
2530        // recovered-but-previously-crashed extension doesn't report Running.
2531        let consecutive = self.restart_count.load(Ordering::Relaxed);
2532        let lifetime = self.total_restarts.load(Ordering::Relaxed);
2533        let max = self.restart_policy.max_attempts as usize;
2534        if consecutive >= max {
2535            ExtensionHealth::Failed
2536        } else if lifetime > 0 {
2537            // Within budget. If the state slot is currently empty, we're
2538            // mid-restart; otherwise the process is alive but has previously
2539            // crashed at least once.
2540            let state_alive = self.state.try_lock().map(|g| g.is_some()).unwrap_or(true);
2541            if state_alive {
2542                ExtensionHealth::Degraded
2543            } else {
2544                ExtensionHealth::Restarting
2545            }
2546        } else {
2547            ExtensionHealth::Running
2548        }
2549    }
2550}
2551
2552#[cfg(test)]
2553mod exclusive_memory_tests {
2554    use super::*;
2555    #[tokio::test]
2556    async fn exclusive_inbox_refuses_legacy_memory_without_writes() {
2557        let mut inbox = Inbox::new("fixture".into());
2558        inbox.exclusive_memory = true;
2559        let inbox = Arc::new(inbox);
2560        for method in ["memory.append", "memory.query"] {
2561            let result = ProcessExtension::handle_inbound_request(
2562                &inbox,
2563                method,
2564                serde_json::json!({"namespace":"fixture","content":"must not persist"}),
2565            )
2566            .await;
2567            assert!(result.unwrap_err().1.contains("disabled"));
2568        }
2569    }
2570}
2571
2572#[cfg(test)]
2573mod tool_call_params_tests {
2574    use super::*;
2575    use serde_json::json;
2576
2577    #[test]
2578    fn carries_caller_session_id() {
2579        assert_eq!(
2580            tool_call_params_gated("echo", json!({"text": "hi"}), Some("sess-A"), true),
2581            json!({"name": "echo", "input": {"text": "hi"}, "session_id": "sess-A"})
2582        );
2583    }
2584
2585    #[test]
2586    fn worker_call_has_null_session_id_key() {
2587        let params = tool_call_params_gated("echo", json!({}), None, true);
2588        assert_eq!(params.get("session_id"), Some(&Value::Null));
2589    }
2590
2591    #[test]
2592    fn kill_switch_forces_null() {
2593        let params = tool_call_params_gated("echo", json!({}), Some("sess-A"), false);
2594        assert_eq!(params.get("session_id"), Some(&Value::Null));
2595        assert_eq!(params["name"], json!("echo"));
2596    }
2597}
2598
2599#[cfg(test)]
2600mod stream_event_tests {
2601    use super::*;
2602    use serde_json::json;
2603
2604    #[test]
2605    fn parses_text_delta_with_delta_key() {
2606        let v = json!({"type": "text", "delta": "hi"});
2607        assert_eq!(
2608            parse_provider_stream_event(&v).unwrap(),
2609            ProviderStreamEvent::TextDelta { text: "hi".into() }
2610        );
2611    }
2612
2613    #[test]
2614    fn parses_text_delta_with_text_key() {
2615        let v = json!({"type": "text", "text": "hi"});
2616        assert_eq!(
2617            parse_provider_stream_event(&v).unwrap(),
2618            ProviderStreamEvent::TextDelta { text: "hi".into() }
2619        );
2620    }
2621
2622    #[test]
2623    fn parses_thinking_delta() {
2624        let v = json!({"type": "thinking", "delta": "hmm"});
2625        assert_eq!(
2626            parse_provider_stream_event(&v).unwrap(),
2627            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2628        );
2629        let v2 = json!({"type": "thinking", "text": "hmm"});
2630        assert_eq!(
2631            parse_provider_stream_event(&v2).unwrap(),
2632            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2633        );
2634    }
2635
2636    #[test]
2637    fn parses_tool_use() {
2638        let v = json!({
2639            "type": "tool_use",
2640            "id": "t1",
2641            "name": "echo",
2642            "input": {"x": 1}
2643        });
2644        assert_eq!(
2645            parse_provider_stream_event(&v).unwrap(),
2646            ProviderStreamEvent::ToolUse {
2647                id: "t1".into(),
2648                name: "echo".into(),
2649                input: json!({"x": 1}),
2650            }
2651        );
2652    }
2653
2654    #[test]
2655    fn tool_use_input_defaults_to_empty_object() {
2656        let v = json!({"type": "tool_use", "id": "t1", "name": "echo"});
2657        assert_eq!(
2658            parse_provider_stream_event(&v).unwrap(),
2659            ProviderStreamEvent::ToolUse {
2660                id: "t1".into(),
2661                name: "echo".into(),
2662                input: json!({}),
2663            }
2664        );
2665    }
2666
2667    #[test]
2668    fn parses_usage_strips_type() {
2669        let v = json!({"type": "usage", "input_tokens": 5, "output_tokens": 7});
2670        assert_eq!(
2671            parse_provider_stream_event(&v).unwrap(),
2672            ProviderStreamEvent::Usage {
2673                usage: json!({"input_tokens": 5, "output_tokens": 7})
2674            }
2675        );
2676    }
2677
2678    #[test]
2679    fn parses_error() {
2680        let v = json!({"type": "error", "message": "boom"});
2681        assert_eq!(
2682            parse_provider_stream_event(&v).unwrap(),
2683            ProviderStreamEvent::Error {
2684                message: "boom".into()
2685            }
2686        );
2687    }
2688
2689    #[test]
2690    fn parses_done() {
2691        let v = json!({"type": "done"});
2692        assert_eq!(
2693            parse_provider_stream_event(&v).unwrap(),
2694            ProviderStreamEvent::Done
2695        );
2696    }
2697
2698    #[test]
2699    fn nested_event_shape_matches_flat() {
2700        let flat = json!({"type": "text", "delta": "hi"});
2701        let nested = json!({"event": {"type": "text", "delta": "hi"}});
2702        assert_eq!(
2703            parse_provider_stream_event(&flat).unwrap(),
2704            parse_provider_stream_event(&nested).unwrap()
2705        );
2706    }
2707
2708    #[test]
2709    fn missing_type_errors() {
2710        let v = json!({"delta": "hi"});
2711        let err = parse_provider_stream_event(&v).unwrap_err();
2712        assert!(err.contains("missing type"), "got: {err}");
2713    }
2714
2715    #[test]
2716    fn unknown_type_errors_with_type() {
2717        let v = json!({"type": "wat"});
2718        let err = parse_provider_stream_event(&v).unwrap_err();
2719        assert!(err.contains("wat"), "got: {err}");
2720    }
2721
2722    #[test]
2723    fn tool_use_missing_id_errors() {
2724        let v = json!({"type": "tool_use", "name": "echo"});
2725        let err = parse_provider_stream_event(&v).unwrap_err();
2726        assert!(err.contains("id"), "got: {err}");
2727    }
2728
2729    #[test]
2730    fn tool_use_missing_name_errors() {
2731        let v = json!({"type": "tool_use", "id": "t1"});
2732        let err = parse_provider_stream_event(&v).unwrap_err();
2733        assert!(err.contains("name"), "got: {err}");
2734    }
2735
2736    #[test]
2737    fn tool_use_empty_id_errors() {
2738        let v = json!({"type": "tool_use", "id": "", "name": "echo"});
2739        assert!(parse_provider_stream_event(&v).is_err());
2740    }
2741
2742    #[test]
2743    fn tool_use_empty_name_errors() {
2744        let v = json!({"type": "tool_use", "id": "t1", "name": ""});
2745        assert!(parse_provider_stream_event(&v).is_err());
2746    }
2747
2748    #[test]
2749    fn tool_use_non_object_input_errors() {
2750        let v = json!({"type": "tool_use", "id": "t1", "name": "echo", "input": "nope"});
2751        let err = parse_provider_stream_event(&v).unwrap_err();
2752        assert!(err.contains("input"), "got: {err}");
2753    }
2754
2755    #[test]
2756    fn text_missing_delta_and_text_errors() {
2757        let v = json!({"type": "text"});
2758        let err = parse_provider_stream_event(&v).unwrap_err();
2759        assert!(err.contains("delta") || err.contains("text"), "got: {err}");
2760    }
2761
2762    #[test]
2763    fn error_missing_message_errors() {
2764        let v = json!({"type": "error"});
2765        assert!(parse_provider_stream_event(&v).is_err());
2766    }
2767
2768    #[test]
2769    fn error_empty_message_errors() {
2770        let v = json!({"type": "error", "message": ""});
2771        assert!(parse_provider_stream_event(&v).is_err());
2772    }
2773}
2774
2775#[cfg(test)]
2776mod restart_policy_tests {
2777    use super::*;
2778
2779    #[tokio::test]
2780    async fn restart_policy_default_max_attempts_is_3() {
2781        // Use a command that won't actually run; we only need the struct.
2782        // Since `spawn` actually launches the process, use a trivial echoer
2783        // and immediately shut it down. Falls back to /bin/cat which reads
2784        // from stdin and stays alive.
2785        let ext = ProcessExtension::spawn("policy-test", "/bin/cat", &[])
2786            .await
2787            .expect("spawn /bin/cat");
2788        assert_eq!(ext.restart_policy.max_attempts, 3);
2789        ext.shutdown().await;
2790    }
2791
2792    #[tokio::test]
2793    async fn with_restart_policy_overrides_default() {
2794        let ext = ProcessExtension::spawn("policy-test-override", "/bin/cat", &[])
2795            .await
2796            .expect("spawn /bin/cat");
2797        let custom = RestartPolicy {
2798            max_attempts: 7,
2799            ..RestartPolicy::default()
2800        };
2801        let ext = ext.with_restart_policy(custom);
2802        assert_eq!(ext.restart_policy.max_attempts, 7);
2803        ext.shutdown().await;
2804    }
2805}
2806
2807#[cfg(test)]
2808mod capture_validator_tests {
2809    use super::*;
2810    use crate::extensions::permissions::{Permission, PermissionSet};
2811
2812    fn perms_with(grants: &[Permission]) -> PermissionSet {
2813        let mut p = PermissionSet::new();
2814        for g in grants {
2815            p.grant(*g);
2816        }
2817        p
2818    }
2819
2820    fn cap(kind: &str, name: &str, perms: &[&str]) -> CapabilityDeclaration {
2821        CapabilityDeclaration {
2822            kind: kind.to_string(),
2823            name: name.to_string(),
2824            permissions: perms.iter().map(|p| p.to_string()).collect(),
2825            params: serde_json::Value::Null,
2826        }
2827    }
2828
2829    #[test]
2830    fn capability_validator_rejects_empty_kind() {
2831        let d = cap("   ", "Sample", &["audio.input"]);
2832        let perms = perms_with(&[Permission::AudioInput]);
2833        let err = validate_capability(&d, &perms).unwrap_err();
2834        assert!(err.contains("kind"), "got: {}", err);
2835    }
2836
2837    #[test]
2838    fn capability_validator_rejects_empty_name() {
2839        let d = cap("capture", "   ", &["audio.input"]);
2840        let perms = perms_with(&[Permission::AudioInput]);
2841        let err = validate_capability(&d, &perms).unwrap_err();
2842        assert!(err.contains("name"), "got: {}", err);
2843    }
2844
2845    #[test]
2846    fn capability_validator_rejects_unknown_permission_string() {
2847        let d = cap("capture", "Sample", &["audio.telepathy"]);
2848        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2849        let err = validate_capability(&d, &perms).unwrap_err();
2850        assert!(
2851            err.contains("unknown permission") && err.contains("audio.telepathy"),
2852            "got: {}",
2853            err,
2854        );
2855    }
2856
2857    #[test]
2858    fn capability_validator_requires_every_declared_permission() {
2859        let d = cap("capture", "Sample", &["audio.input"]);
2860        let perms = perms_with(&[]);
2861        let err = validate_capability(&d, &perms).unwrap_err();
2862        assert!(
2863            err.contains("audio.input") && err.contains("not granted"),
2864            "got: {}",
2865            err,
2866        );
2867    }
2868
2869    #[test]
2870    fn capability_validator_accepts_when_all_permissions_granted() {
2871        let d = cap("capture", "Sample", &["audio.input", "audio.output"]);
2872        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2873        validate_capability(&d, &perms).expect("should validate");
2874    }
2875
2876    #[test]
2877    fn capability_validator_accepts_no_permissions() {
2878        // Capabilities that genuinely don't need any permission (e.g. a
2879        // pure-CPU OCR sidecar that takes images via stdin) should
2880        // validate cleanly with an empty permission list.
2881        let d = cap("ocr", "Tesseract", &[]);
2882        let perms = perms_with(&[]);
2883        validate_capability(&d, &perms).expect("should validate");
2884    }
2885
2886    #[test]
2887    fn capability_validator_does_not_branch_on_kind() {
2888        // Two capabilities with identical permission requirements but
2889        // different `kind` values both validate identically — proves
2890        // the validator does not enumerate kinds.
2891        let perms = perms_with(&[Permission::AudioInput]);
2892        for kind in ["capture", "ocr", "agent", "foot_pedal", "eeg"] {
2893            let d = cap(kind, "Anything", &["audio.input"]);
2894            validate_capability(&d, &perms).expect("should validate");
2895        }
2896    }
2897}
2898
2899#[cfg(test)]
2900mod invoke_command_dispatch_tests {
2901    //! Phase B Phase 2/3 — exercise the notification dispatcher used by
2902    //! `command.invoke`. Spawning a real subprocess is not required: we feed
2903    //! `NotificationFrame`s through the production async forwarder into the
2904    //! bounded CP-11 fix-3 sink and assert the collected ordering.
2905    use super::*;
2906    use crate::extensions::commands::CommandOutputEvent;
2907    use crate::extensions::invoke_output::{invoke_event_channel, InvokeOutputBudget};
2908    use crate::extensions::runtime::InvokeCommandEvent;
2909    use crate::extensions::tasks::{TaskEvent, TaskKind};
2910    use serde_json::json;
2911
2912    fn frame(method: &str, params: serde_json::Value) -> NotificationFrame {
2913        NotificationFrame {
2914            method: method.to_string(),
2915            params,
2916        }
2917    }
2918
2919    /// Feed `frames` through the production forwarder into the bounded
2920    /// sink (the small frame counts here never exceed channel capacity,
2921    /// so sequential awaited sends cannot park), then collect. Returns
2922    /// the retained events and whether a Done marker was observed.
2923    async fn dispatch(frames: Vec<NotificationFrame>) -> (Vec<InvokeCommandEvent>, bool) {
2924        let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
2925        let mut open = true;
2926        let mut saw_done = false;
2927        for f in frames {
2928            saw_done |=
2929                ProcessExtension::forward_invoke_command_frame("ext-test", "r1", &tx, &mut open, f)
2930                    .await;
2931        }
2932        drop(tx);
2933        let report = collector.collect().await;
2934        (report.events, saw_done)
2935    }
2936
2937    #[tokio::test]
2938    async fn forwards_mixed_event_stream_in_order() {
2939        let frames = vec![
2940            frame(
2941                "command.output",
2942                json!({"request_id":"r1","event":{"kind":"text","content":"A"}}),
2943            ),
2944            frame(
2945                "task.start",
2946                json!({"id":"dl","label":"Downloading","kind":"download"}),
2947            ),
2948            frame("task.update", json!({"id":"dl","current":50,"total":100})),
2949            frame(
2950                "command.output",
2951                json!({"request_id":"r1","event":{"kind":"system","content":"working"}}),
2952            ),
2953            frame("task.done", json!({"id":"dl"})),
2954            frame(
2955                "command.output",
2956                json!({"request_id":"r1","event":{"kind":"done"}}),
2957            ),
2958        ];
2959
2960        let (events, saw_done) = dispatch(frames).await;
2961        assert!(saw_done, "should have observed the command Done marker");
2962        assert_eq!(events.len(), 6);
2963        assert_eq!(
2964            events[0],
2965            InvokeCommandEvent::Output(CommandOutputEvent::Text {
2966                content: "A".into()
2967            })
2968        );
2969        assert!(matches!(
2970            events[1],
2971            InvokeCommandEvent::Task(TaskEvent::Start {
2972                kind: TaskKind::Download,
2973                ..
2974            })
2975        ));
2976        assert!(matches!(
2977            events[2],
2978            InvokeCommandEvent::Task(TaskEvent::Update { .. })
2979        ));
2980        assert!(matches!(
2981            events[3],
2982            InvokeCommandEvent::Output(CommandOutputEvent::System { .. })
2983        ));
2984        assert!(matches!(
2985            events[4],
2986            InvokeCommandEvent::Task(TaskEvent::Done { error: None, .. })
2987        ));
2988        assert_eq!(
2989            events[5],
2990            InvokeCommandEvent::Output(CommandOutputEvent::Done)
2991        );
2992    }
2993
2994    #[tokio::test]
2995    async fn ignores_command_output_for_unrelated_request_id() {
2996        let (events, saw_done) = dispatch(vec![frame(
2997            "command.output",
2998            json!({"request_id":"other","event":{"kind":"text","content":"x"}}),
2999        )])
3000        .await;
3001        assert!(!saw_done);
3002        assert!(events.is_empty());
3003    }
3004
3005    #[tokio::test]
3006    async fn skips_malformed_command_output_without_aborting() {
3007        let (events, saw_done) = dispatch(vec![
3008            // Missing 'kind'
3009            frame("command.output", json!({"request_id":"r1","event":{}})),
3010            // Followed by a good event — must still be delivered.
3011            frame(
3012                "command.output",
3013                json!({"request_id":"r1","event":{"kind":"done"}}),
3014            ),
3015        ])
3016        .await;
3017        assert!(saw_done);
3018        assert_eq!(
3019            events,
3020            vec![InvokeCommandEvent::Output(CommandOutputEvent::Done)]
3021        );
3022    }
3023
3024    #[tokio::test]
3025    async fn task_events_pass_through_regardless_of_request_id() {
3026        let (events, _) = dispatch(vec![frame("task.log", json!({"id":"abc","line":"..."}))]).await;
3027        match &events[..] {
3028            [InvokeCommandEvent::Task(TaskEvent::Log { id, line })] => {
3029                assert_eq!(id, "abc");
3030                assert_eq!(line, "...");
3031            }
3032            other => panic!("unexpected: {other:?}"),
3033        }
3034    }
3035
3036    #[tokio::test]
3037    async fn unrelated_methods_are_dropped() {
3038        let (events, saw_done) = dispatch(vec![frame(
3039            "provider.stream.event",
3040            json!({"type":"text","delta":"x"}),
3041        )])
3042        .await;
3043        assert!(!saw_done);
3044        assert!(events.is_empty());
3045    }
3046
3047    /// A closed collector (dropped receiver) latches `sink_open` false and
3048    /// never blocks or aborts the dispatch loop.
3049    #[tokio::test]
3050    async fn closed_sink_latches_open_false_without_blocking() {
3051        let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
3052        drop(collector);
3053        let mut open = true;
3054        let saw_done = ProcessExtension::forward_invoke_command_frame(
3055            "ext-test",
3056            "r1",
3057            &tx,
3058            &mut open,
3059            frame(
3060                "command.output",
3061                json!({"request_id":"r1","event":{"kind":"done"}}),
3062            ),
3063        )
3064        .await;
3065        // Done detection is independent of sink state.
3066        assert!(saw_done);
3067        assert!(!open);
3068    }
3069}
3070
3071#[cfg(all(test, unix))]
3072mod lifecycle_tests {
3073    use super::*;
3074    use std::time::Duration;
3075
3076    // Offline stdio fixture: read exactly one request frame, then echo its id.
3077    // No Python, network, model calls, or filesystem fixtures are required.
3078    async fn fixture() -> ProcessExtension {
3079        let result = serde_json::json!({
3080            "protocol_version": CURRENT_EXTENSION_PROTOCOL_VERSION,
3081            "capabilities": {}
3082        })
3083        .to_string();
3084        let script = format!(
3085            r#"while IFS= read -r header; do
3086    length=${{header#Content-Length: }}
3087    length=${{length%?}}
3088    IFS= read -r blank || exit 1
3089    body=$(dd bs=1 count="$length" 2>/dev/null) || exit 1
3090    id=${{body##*\"id\":}}
3091    id=${{id%%[!0-9]*}}
3092    case "$body" in
3093        *'"method":"pending"'*) IFS= read -r hold; continue ;;
3094    esac
3095    reply='{{"jsonrpc":"2.0","id":'"$id"',"result":{result}}}'
3096    printf 'Content-Length: %s\r\n\r\n%s' "${{#reply}}" "$reply"
3097done"#
3098        );
3099        let ext = ProcessExtension::spawn("lifecycle-fixture", "/bin/sh", &["-c".into(), script])
3100            .await
3101            .expect("spawn offline lifecycle fixture");
3102        tokio::time::timeout(Duration::from_secs(3), ext.initialize_for_test(None))
3103            .await
3104            .expect("bounded initialize")
3105            .expect("initialize fixture");
3106        ext
3107    }
3108
3109    /// Offline fixture that answers `initialize` normally and every other
3110    /// method with a JSON-RPC `-32601` error object (a live, healthy process
3111    /// that simply lacks the method — e.g. the optional `info.get` probe).
3112    async fn unknown_method_fixture() -> ProcessExtension {
3113        let init = serde_json::json!({
3114            "protocol_version": CURRENT_EXTENSION_PROTOCOL_VERSION,
3115            "capabilities": {}
3116        })
3117        .to_string();
3118        let script = format!(
3119            r#"while IFS= read -r header; do
3120    length=${{header#Content-Length: }}
3121    length=${{length%?}}
3122    IFS= read -r blank || exit 1
3123    body=$(dd bs=1 count="$length" 2>/dev/null) || exit 1
3124    id=${{body##*\"id\":}}
3125    id=${{id%%[!0-9]*}}
3126    case "$body" in
3127        *'"method":"initialize"'*) reply='{{"jsonrpc":"2.0","id":'"$id"',"result":{init}}}' ;;
3128        *) reply='{{"jsonrpc":"2.0","id":'"$id"',"error":{{"code":-32601,"message":"unknown method"}}}}' ;;
3129    esac
3130    printf 'Content-Length: %s
3131
3132%s' "${{#reply}}" "$reply"
3133done"#
3134        );
3135        let ext =
3136            ProcessExtension::spawn("unknown-method-fixture", "/bin/sh", &["-c".into(), script])
3137                .await
3138                .expect("spawn offline unknown-method fixture");
3139        tokio::time::timeout(Duration::from_secs(3), ext.initialize_for_test(None))
3140            .await
3141            .expect("bounded initialize")
3142            .expect("initialize fixture");
3143        ext
3144    }
3145
3146    /// A JSON-RPC `error` reply proves the process is alive: the call must
3147    /// surface it verbatim, with no restart, no generation change and no
3148    /// consumption of the restart budget. (Regression: the optional
3149    /// `info.get` probe used to re-spawn every tool-bearing extension at
3150    /// boot just to hear "unknown method" a second time.)
3151    #[tokio::test]
3152    async fn application_error_reply_never_restarts_the_process() {
3153        let ext = unknown_method_fixture().await;
3154        let before = ext.lifecycle_snapshot().unwrap();
3155        for method in ["info.get", "sidecar.spawn_args", "no.such.method"] {
3156            let error = tokio::time::timeout(Duration::from_secs(3), ext.call(method, Value::Null))
3157                .await
3158                .expect("bounded call")
3159                .expect_err("fixture answers every non-initialize method with an error");
3160            assert!(is_application_error(&error), "{method}: {error}");
3161            assert!(error.contains("unknown method"), "{method}: {error}");
3162            assert!(
3163                !error.contains("retry after restart failed"),
3164                "{method}: must not restart on an application error: {error}"
3165            );
3166        }
3167        assert_eq!(ext.total_restarts.load(Ordering::Relaxed), 0);
3168        assert_eq!(ext.restart_count.load(Ordering::Relaxed), 0);
3169        let after = ext.lifecycle_snapshot().unwrap();
3170        assert_eq!(after.generation, before.generation);
3171        assert_eq!(after.health, before.health);
3172        // Transport failures still take the restart path.
3173        assert!(!is_application_error(
3174            "transport closed: response channel dropped"
3175        ));
3176        assert!(!is_application_error("Write error: broken pipe"));
3177        ext.force_shutdown().await;
3178    }
3179
3180    async fn wait_failed(ext: &ProcessExtension) -> ExtensionLifecycle {
3181        tokio::time::timeout(Duration::from_secs(3), async {
3182            loop {
3183                if let Some(snapshot) = ext.lifecycle_snapshot() {
3184                    if snapshot.health == ExtensionHealth::Failed {
3185                        return snapshot;
3186                    }
3187                }
3188                tokio::time::sleep(Duration::from_millis(5)).await;
3189            }
3190        })
3191        .await
3192        .expect("lifecycle must detect failure without another RPC")
3193    }
3194
3195    #[tokio::test]
3196    async fn actual_child_exit_is_detected_while_rpc_holds_state_and_reader_is_stopped() {
3197        let ext = fixture().await;
3198        let before = ext.lifecycle_snapshot().expect("live snapshot");
3199        assert_eq!(before.health, ExtensionHealth::Running);
3200        let call_guard = ext.call_lock.lock().await;
3201        let mut state_guard = ext.state.lock().await;
3202        let state = state_guard.as_mut().unwrap();
3203        let lifecycle = state.lifecycle.clone();
3204        state.reader_handle.abort();
3205        // No reader can observe EOF or close the response channel in this test.
3206        tokio::time::timeout(Duration::from_secs(3), async {
3207            while !state.reader_handle.is_finished() {
3208                tokio::task::yield_now().await;
3209            }
3210        })
3211        .await
3212        .expect("reader cancellation");
3213        {
3214            let rpc = ext.call_once_locked(state, "pending", Value::Null, 900);
3215            tokio::pin!(rpc);
3216            tokio::select! {
3217                biased;
3218                result = &mut rpc => panic!("RPC must remain pending: {result:?}"),
3219                _ = tokio::time::sleep(Duration::from_millis(20)) => {}
3220            }
3221            assert!(ext.inbox.pending.lock().await.contains_key(&900));
3222            assert_eq!(ext.lifecycle_snapshot(), Some(before));
3223            lifecycle.child.lock().await.start_kill().unwrap();
3224            let dead = wait_failed(&ext).await;
3225            assert_ne!(dead.generation, before.generation);
3226            assert_eq!(ext.lifecycle_snapshot(), Some(dead));
3227            assert!(!ext.inbox.closed.load(Ordering::Acquire));
3228            assert!(ext.inbox.pending.lock().await.contains_key(&900));
3229        }
3230        drop(state_guard);
3231        drop(call_guard);
3232        ext.force_shutdown().await;
3233    }
3234
3235    #[tokio::test]
3236    async fn shutdown_invalidates_generation_before_waiting_for_rpc_lock() {
3237        let ext = Arc::new(fixture().await);
3238        let before = ext.lifecycle_snapshot().unwrap();
3239        let call_guard = ext.call_lock.lock().await;
3240        let shutdown_ext = ext.clone();
3241        let shutdown = tokio::spawn(async move { shutdown_ext.shutdown().await });
3242        let dead = wait_failed(&ext).await;
3243        assert_ne!(dead.generation, before.generation);
3244        assert!(!shutdown.is_finished());
3245        drop(call_guard);
3246        tokio::time::timeout(Duration::from_secs(3), shutdown)
3247            .await
3248            .expect("bounded shutdown")
3249            .expect("shutdown task");
3250        assert_eq!(ext.lifecycle_snapshot(), Some(dead));
3251    }
3252
3253    /// A call that reaches a handler after `shutdown` (it was authorized
3254    /// before an unload or reload) fails; it must not respawn the process
3255    /// and run a second copy beside the replacement.
3256    #[tokio::test]
3257    async fn a_call_after_shutdown_fails_without_respawning() {
3258        let ext = fixture().await;
3259        ext.shutdown().await;
3260        let err = ext
3261            .call_inner("tool.call", serde_json::json!({}))
3262            .await
3263            .expect_err("a stopped handler must refuse the call");
3264        assert!(err.contains("was stopped"), "{err}");
3265        assert!(ext.state.lock().await.is_none(), "no process was respawned");
3266        assert_eq!(ext.total_restarts.load(Ordering::Relaxed), 0);
3267    }
3268
3269    #[tokio::test]
3270    async fn in_place_restart_changes_generation_and_old_invalidation_is_isolated() {
3271        let ext = fixture().await;
3272        let before = ext.lifecycle_snapshot().unwrap();
3273        let mut state = ext.state.lock().await;
3274        let old = state.as_ref().unwrap().lifecycle.clone();
3275        {
3276            // Hold teardown at the child lock to inspect the invalidation window
3277            // before a replacement can be spawned or initialized.
3278            let child_guard = old.child.lock().await;
3279            let restart = ext.restart_locked(&mut state);
3280            tokio::pin!(restart);
3281            tokio::select! {
3282                biased;
3283                result = &mut restart => panic!("restart must await child lock: {result:?}"),
3284                _ = tokio::time::sleep(Duration::from_millis(20)) => {}
3285            }
3286            let replacing = ext.lifecycle_snapshot().unwrap();
3287            assert_ne!(replacing.generation, before.generation);
3288            assert_eq!(replacing.health, ExtensionHealth::Failed);
3289            drop(child_guard);
3290            tokio::time::timeout(Duration::from_secs(5), restart)
3291                .await
3292                .expect("bounded restart")
3293                .expect("restart fixture");
3294        }
3295        let after = ext.lifecycle_snapshot().unwrap();
3296        assert_ne!(after.generation, before.generation);
3297        assert_eq!(after.health, ExtensionHealth::Degraded);
3298        assert_eq!(old.snapshot(false).unwrap().health, ExtensionHealth::Failed);
3299        old.invalidate();
3300        assert_eq!(ext.lifecycle_snapshot(), Some(after));
3301        drop(state);
3302        ext.force_shutdown().await;
3303        let stopped = ext.lifecycle_snapshot().unwrap();
3304        assert_ne!(stopped.generation, after.generation);
3305        assert_eq!(stopped.health, ExtensionHealth::Failed);
3306    }
3307
3308    #[tokio::test]
3309    async fn transport_eof_invalidates_generation_even_while_child_is_alive() {
3310        // Close stdout, but keep the actual child blocked on stdin.
3311        let ext = ProcessExtension::spawn(
3312            "lifecycle-eof",
3313            "/bin/sh",
3314            &[
3315                "-c".into(),
3316                "IFS= read -r trigger; exec 1>&-; IFS= read -r hold".into(),
3317            ],
3318        )
3319        .await
3320        .unwrap();
3321        let mut state_guard = ext.state.lock().await;
3322        let state = state_guard.as_mut().unwrap();
3323        state.lifecycle.initialized.store(true, Ordering::Release);
3324        let before = ext.lifecycle_snapshot().unwrap();
3325        state
3326            .stdin
3327            .lock()
3328            .await
3329            .write_all(b"close\n")
3330            .await
3331            .unwrap();
3332        let dead = wait_failed(&ext).await;
3333        assert_ne!(dead.generation, before.generation);
3334        assert!(state
3335            .lifecycle
3336            .child
3337            .lock()
3338            .await
3339            .try_wait()
3340            .unwrap()
3341            .is_none());
3342        drop(state_guard);
3343        ext.force_shutdown().await;
3344    }
3345
3346    #[tokio::test]
3347    async fn contended_child_probe_is_unavailable_without_changing_generation() {
3348        let ext = fixture().await;
3349        let before = ext.lifecycle_snapshot().unwrap();
3350        let lifecycle = ext.inbox.lifecycle.lock().await.clone().unwrap();
3351        let child_guard = lifecycle.child.lock().await;
3352        assert_eq!(ext.lifecycle_snapshot(), None);
3353        drop(child_guard);
3354        assert_eq!(ext.lifecycle_snapshot(), Some(before));
3355        let slot_guard = ext.inbox.lifecycle.lock().await;
3356        assert_eq!(ext.lifecycle_snapshot(), None);
3357        drop(slot_guard);
3358        assert_eq!(ext.lifecycle_snapshot(), Some(before));
3359        ext.force_shutdown().await;
3360    }
3361}