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::{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, 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        )
266        .await,
267        ctx.capabilities.secret_prompt.as_ref(),
268        false,
269    )
270    .await;
271
272    let crate::runtime::BeforeToolCallDecision::Continue { input } = decision else {
273        let crate::runtime::BeforeToolCallDecision::Block { reason } = decision else {
274            unreachable!()
275        };
276        return serde_json::json!({
277            "type": "tool_result",
278            "tool_use_id": tool_id,
279            "content": format!("Tool call blocked by extension: {}", reason),
280            "is_error": true,
281        });
282    };
283
284    let input_for_hook = input.clone();
285    let (result, is_error) = match tool.execute(input, ctx).await {
286        Ok(output) => (output, false),
287        Err(error) => (format!("Tool execution failed: {}", error), true),
288    };
289    let result = crate::runtime::emit_after_tool_call(
290        hook_bus,
291        &tool_name,
292        Some(&runtime_name),
293        input_for_hook,
294        result,
295        max_tool_output,
296    )
297    .await;
298
299    let mut response = serde_json::json!({
300        "type": "tool_result",
301        "tool_use_id": tool_id,
302        "content": crate::truncate_str(&result, max_tool_output).to_string(),
303    });
304    if is_error {
305        response["is_error"] = serde_json::json!(true);
306    }
307    response
308}
309
310/// Interior extension-provider tool loop. `session_tools` is built ONCE by
311/// the caller (under the same registry read guard passed as `registry`) and
312/// reused for every tool call in every interior round — the registry stays
313/// read-locked for the whole outer provider call, so no catalog drift is
314/// possible inside this loop and one set snapshot governs all rounds.
315#[allow(clippy::too_many_arguments)] // explicit loop policy inputs; grouping would hide registry/session coupling
316pub async fn complete_provider_with_tools<F>(
317    handler: Arc<dyn ExtensionHandler>,
318    mut params: ProviderCompleteParams,
319    registry: &crate::ToolRegistry,
320    session_tools: &crate::tools::activation::SessionToolSet,
321    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
322    mut context_factory: F,
323    max_tool_output: usize,
324    max_iterations: usize,
325    // Out-param: total tool calls the provider requested across every
326    // interior round. An out-param (rather than a return tuple) so the
327    // count is still observable on the error paths below — the audit
328    // record must stay honest when the iteration limit is hit.
329    tools_requested: &mut u32,
330) -> Result<ProviderCompleteResult, String>
331where
332    F: FnMut() -> crate::ToolContext,
333{
334    let max_iterations = max_iterations.max(1);
335    *tools_requested = 0;
336    for iteration in 0..max_iterations {
337        let result = handler.provider_complete(params.clone()).await?;
338        let tool_uses = extract_provider_tool_uses(&result.content)?;
339        if tool_uses.is_empty() {
340            return Ok(result);
341        }
342        // Counted before the limit check: the provider DID request these,
343        // even when we refuse to run another round.
344        *tools_requested = tools_requested.saturating_add(tool_uses.len() as u32);
345        if iteration + 1 == max_iterations {
346            return Err(format!(
347                "extension provider '{}' exceeded provider tool-use iteration limit ({})",
348                handler.id(),
349                max_iterations,
350            ));
351        }
352
353        let assistant_content = result.content.clone();
354        params.messages.push(std::sync::Arc::new(serde_json::json!({
355            "role": "assistant",
356            "content": assistant_content,
357        })));
358
359        let mut tool_results = Vec::with_capacity(tool_uses.len());
360        for tool_use in tool_uses {
361            tool_results.push(
362                execute_provider_tool_use(
363                    registry,
364                    session_tools,
365                    hook_bus,
366                    tool_use,
367                    context_factory(),
368                    max_tool_output,
369                )
370                .await,
371            );
372        }
373        params.messages.push(std::sync::Arc::new(serde_json::json!({
374            "role": "user",
375            "content": tool_results,
376        })));
377    }
378    Err(format!(
379        "extension provider '{}' exceeded provider tool-use iteration limit ({})",
380        handler.id(),
381        max_iterations,
382    ))
383}
384
385pub fn extract_provider_tool_uses(content: &[Value]) -> Result<Vec<ProviderToolUse>, String> {
386    let mut tool_uses = Vec::new();
387    for block in content {
388        if block.get("type").and_then(Value::as_str) != Some("tool_use") {
389            continue;
390        }
391        let id = block
392            .get("id")
393            .and_then(Value::as_str)
394            .ok_or_else(|| "provider tool_use missing id".to_string())?;
395        let name = block
396            .get("name")
397            .and_then(Value::as_str)
398            .ok_or_else(|| "provider tool_use missing name".to_string())?;
399        if id.trim().is_empty() {
400            return Err("provider tool_use id is empty".to_string());
401        }
402        if name.trim().is_empty() {
403            return Err("provider tool_use name is empty".to_string());
404        }
405        let input = block
406            .get("input")
407            .cloned()
408            .unwrap_or_else(|| serde_json::json!({}));
409        if !input.is_object() {
410            return Err(format!(
411                "provider tool_use '{}' input must be a JSON object",
412                id
413            ));
414        }
415        tool_uses.push(ProviderToolUse {
416            id: id.to_string(),
417            name: name.to_string(),
418            input,
419        });
420    }
421    Ok(tool_uses)
422}
423
424#[derive(Debug, Clone, Default, PartialEq)]
425pub struct InitializeCapabilitiesResult {
426    pub tools: Vec<RegisteredExtensionToolSpec>,
427    pub providers: Vec<RegisteredProviderSpec>,
428    /// Generic, plugin-defined capabilities. Each entry's `kind` is a
429    /// free-form string the plugin author picks (e.g. `"capture"`,
430    /// `"ocr"`, `"agent"`, `"foot_pedal"`); core does not enumerate or
431    /// branch on it. Permissions are gated by the declared
432    /// [`CapabilityDeclaration::permissions`] list.
433    pub capabilities: Vec<CapabilityDeclaration>,
434}
435
436/// Generic capability declaration returned by an extension's
437/// `initialize` response.
438///
439/// Core does not interpret `kind` or `params` — those are contracts
440/// between the plugin and whatever consumer (chatui glue, another
441/// plugin, an external watcher) cares about that capability kind. The
442/// host only enforces that the plugin holds every permission it
443/// declares it needs.
444#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
445pub struct CapabilityDeclaration {
446    /// Free-form capability kind tag chosen by the plugin author.
447    /// Examples: `"capture"`, `"ocr"`, `"agent"`, `"clipboard_mirror"`.
448    /// Must be non-empty; otherwise opaque to core.
449    pub kind: String,
450    /// Human-readable display name shown in `/extensions status` and
451    /// similar surfaces. Must be non-empty.
452    pub name: String,
453    /// Permission names this capability needs at runtime, e.g.
454    /// `["audio.input"]`. Every entry must parse to a known
455    /// [`crate::extensions::permissions::Permission`] *and* be present
456    /// in the extension's granted permission set.
457    #[serde(default)]
458    pub permissions: Vec<String>,
459    /// Free-form metadata. Core stores and forwards but never
460    /// interprets. Plugins use this for endpoint URLs, mode lists,
461    /// version tags, anything that doesn't fit the typed slots.
462    #[serde(default, skip_serializing_if = "is_null_value")]
463    pub params: serde_json::Value,
464}
465
466fn is_null_value(v: &serde_json::Value) -> bool {
467    v.is_null()
468}
469
470/// Validate a [`CapabilityDeclaration`] against the granted permission
471/// set.
472///
473/// Rules — generic, no enumeration of capability kinds:
474/// - `kind` must be non-empty.
475/// - `name` must be non-empty.
476/// - Every string in `permissions` must parse to a known
477///   [`crate::extensions::permissions::Permission`].
478/// - Every parsed permission must be present in `granted`.
479pub fn validate_capability(
480    decl: &CapabilityDeclaration,
481    granted: &crate::extensions::permissions::PermissionSet,
482) -> Result<(), String> {
483    use crate::extensions::permissions::Permission;
484    if decl.kind.trim().is_empty() {
485        return Err("capability 'kind' must be non-empty".to_string());
486    }
487    if decl.name.trim().is_empty() {
488        return Err("capability 'name' must be non-empty".to_string());
489    }
490    for perm_name in &decl.permissions {
491        let parsed = Permission::parse(perm_name).ok_or_else(|| {
492            format!(
493                "capability '{}' declares unknown permission '{}'",
494                decl.kind, perm_name
495            )
496        })?;
497        if !granted.has(parsed) {
498            return Err(format!(
499                "capability '{}' requires permission '{}' but it is not granted",
500                decl.kind, perm_name
501            ));
502        }
503    }
504    Ok(())
505}
506
507#[derive(Deserialize)]
508struct InitializeResult {
509    protocol_version: u32,
510    #[serde(default)]
511    capabilities: InitializeCapabilities,
512}
513
514#[derive(Default, Deserialize)]
515struct InitializeCapabilities {
516    #[serde(default)]
517    tools: Vec<RegisteredExtensionToolSpec>,
518    #[serde(default)]
519    providers: Vec<RegisteredProviderSpec>,
520    /// Canonical generic capability list.
521    #[serde(default)]
522    capabilities: Vec<CapabilityDeclaration>,
523}
524
525/// A JSON-RPC notification frame received from an extension (no `id`).
526///
527/// Internal API exposed publicly (`#[doc(hidden)]`) so integration tests
528/// can subscribe to notifications via [`ProcessExtension::subscribe_notifications`].
529/// Extension authors should not depend on this type — it may change without notice.
530#[doc(hidden)]
531#[derive(Debug, Clone)]
532pub struct NotificationFrame {
533    pub method: String,
534    pub params: Value,
535}
536
537/// CP-11 fix-2 (B): bounded notification handoff. Combined with the 4 MiB
538/// per-frame reader cap, queue capacity fixes the worst-case bytes parked
539/// between the sidecar reader and a subscriber; beyond it the reader
540/// AWAITS (backpressure onto the child's stdout pipe) instead of retaining
541/// hostile flood frames in host memory.
542pub const NOTIFICATION_QUEUE_CAPACITY: usize = 8;
543/// CP-11 fix-2 (B): bounded provider stream-event handoff between the
544/// `provider_stream` notification loop and the route forwarder.
545pub const PROVIDER_EVENT_QUEUE_CAPACITY: usize = 8;
546
547/// Shared mailbox for the background reader task. Holds in-flight request
548/// senders (keyed by JSON-RPC id) and an optional notification subscriber.
549///
550/// Persists across process restarts: a new reader task replaces the old one
551/// but the `Inbox` itself is reused. Pending requests are drained with
552/// errors when the reader observes EOF or a transport failure.
553struct Inbox {
554    pending: Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>,
555    /// Fan-out notification subscribers. Each subscriber is keyed by a monotonic
556    /// id so it can unsubscribe itself without disturbing other concurrent
557    /// subscribers (e.g. background watchers that overlap with
558    /// `command.invoke` / `provider.stream`). Dead senders (receiver dropped)
559    /// are pruned lazily on dispatch and on `unsubscribe_notifications`.
560    /// BOUNDED ([`NOTIFICATION_QUEUE_CAPACITY`]): a subscriber that stops
561    /// draining backpressures the reader instead of growing host memory.
562    notification_sinks: Mutex<Vec<(usize, mpsc::Sender<NotificationFrame>)>>,
563    /// Monotonic id allocator for `notification_sinks`. Wraparound is not a
564    /// concern in practice — `usize` is enormous relative to subscription
565    /// lifetimes — but uniqueness is only required among live subscribers.
566    next_sink_id: std::sync::atomic::AtomicUsize,
567    /// Set to true when the reader task exits (EOF or error). Used to prevent
568    /// callers from registering pending requests that will never be fulfilled.
569    closed: std::sync::atomic::AtomicBool,
570    /// Permissions granted to the calling extension. Set after manifest
571    /// validation; checked by inbound RPC handlers (e.g. memory.append).
572    permissions: RwLock<Option<crate::extensions::permissions::PermissionSet>>,
573    /// Stdin handle of the currently-running child process. Used by the
574    /// reader task to write JSON-RPC responses for inbound requests.
575    /// Replaced on each spawn (initial spawn + restarts).
576    inbound_stdin: Mutex<Option<Arc<Mutex<ChildStdin>>>>,
577    /// Extension id, used for namespace policy and diagnostics.
578    extension_id: String,
579}
580
581impl Inbox {
582    fn new(extension_id: String) -> Self {
583        Self {
584            pending: Mutex::new(HashMap::new()),
585            notification_sinks: Mutex::new(Vec::new()),
586            next_sink_id: std::sync::atomic::AtomicUsize::new(0),
587            closed: std::sync::atomic::AtomicBool::new(false),
588            permissions: RwLock::new(None),
589            inbound_stdin: Mutex::new(None),
590            extension_id,
591        }
592    }
593
594    /// Drains all pending request senders, sending `Err(reason)` to each.
595    /// Also marks the inbox as closed so no new requests can be registered.
596    async fn fail_all_pending(&self, reason: &str) {
597        self.closed
598            .store(true, std::sync::atomic::Ordering::Release);
599        let drained: Vec<_> = {
600            let mut pending = self.pending.lock().await;
601            pending.drain().collect()
602        };
603        for (_, tx) in drained {
604            let _ = tx.send(Err(reason.to_string()));
605        }
606    }
607}
608
609struct ProcessState {
610    child: Child,
611    stdin: Arc<Mutex<ChildStdin>>,
612    reader_handle: JoinHandle<()>,
613}
614
615/// A running extension process communicating via JSON-RPC 2.0 over stdio.
616pub struct ProcessExtension {
617    id: String,
618    command: String,
619    args: Vec<String>,
620    cwd: Option<PathBuf>,
621    state: Arc<Mutex<Option<ProcessState>>>,
622    /// Serializes a full request/response exchange and restart attempts.
623    call_lock: Arc<Mutex<()>>,
624    next_id: AtomicU64,
625    restart_count: AtomicUsize,
626    /// Last resolved initialize config. Kept in memory only so a restarted
627    /// extension receives the same manifest-resolved values (including secrets).
628    /// Never written to disk or emitted in logs.
629    initialize_config: Mutex<Value>,
630    /// Lifetime restart total — never reset. `restart_count` tracks
631    /// *consecutive* failures (reset on successful restart, H-8) for the
632    /// exhaustion budget; this one feeds health/reporting so a previously
633    /// crashed extension still reads Degraded instead of pretending it
634    /// never crashed.
635    total_restarts: AtomicUsize,
636    /// Restart policy controlling exponential backoff and budget.
637    pub(crate) restart_policy: RestartPolicy,
638    /// Shared mailbox between the reader task and request callers. Persists
639    /// across process restarts so that any active notification subscriber
640    /// survives a restart-on-error.
641    inbox: Arc<Inbox>,
642}
643
644impl ProcessExtension {
645    /// Abort the child transport immediately. Cancellation uses this rather
646    /// than waiting for the per-call permit; draining pending responders
647    /// releases every blocked call/producer.
648    pub async fn force_shutdown(&self) {
649        let mut state_guard = self.state.lock().await;
650        if let Some(state) = state_guard.take() {
651            state.reader_handle.abort();
652            let mut child = state.child;
653            let _ = child.kill().await;
654        }
655        drop(state_guard);
656        self.inbox.closed.store(true, Ordering::Release);
657        self.inbox.notification_sinks.lock().await.clear();
658        self.inbox
659            .fail_all_pending("transport closed: extension cancelled")
660            .await;
661    }
662
663    pub async fn spawn(id: &str, command: &str, args: &[String]) -> Result<Self, String> {
664        Self::spawn_with_cwd(id, command, args, None).await
665    }
666
667    /// Spawn `command` with `args` and optional working directory.
668    ///
669    /// Child stderr is captured and forwarded to debug tracing with the extension
670    /// id so extension authors can inspect diagnostics without corrupting stdout.
671    pub async fn spawn_with_cwd(
672        id: &str,
673        command: &str,
674        args: &[String],
675        cwd: Option<PathBuf>,
676    ) -> Result<Self, String> {
677        let inbox = Arc::new(Inbox::new(id.to_string()));
678        let state = Self::spawn_state(id, command, args, cwd.as_ref(), inbox.clone()).await?;
679        Ok(Self {
680            id: id.to_string(),
681            command: command.to_string(),
682            args: args.to_vec(),
683            cwd,
684            state: Arc::new(Mutex::new(Some(state))),
685            call_lock: Arc::new(Mutex::new(())),
686            next_id: AtomicU64::new(1),
687            restart_count: AtomicUsize::new(0),
688            initialize_config: Mutex::new(Value::Object(Default::default())),
689            total_restarts: AtomicUsize::new(0),
690            restart_policy: RestartPolicy::default(),
691            inbox,
692        })
693    }
694
695    /// Override the restart policy. Intended for tests.
696    pub fn with_restart_policy(mut self, policy: RestartPolicy) -> Self {
697        self.restart_policy = policy;
698        self
699    }
700
701    async fn spawn_state(
702        id: &str,
703        command: &str,
704        args: &[String],
705        cwd: Option<&PathBuf>,
706        inbox: Arc<Inbox>,
707    ) -> Result<ProcessState, String> {
708        let mut cmd = Command::new(command);
709        cmd.args(args)
710            .stdin(Stdio::piped())
711            .stdout(Stdio::piped())
712            .stderr(Stdio::piped());
713        if let Some(cwd) = cwd {
714            cmd.current_dir(cwd);
715        }
716
717        // Clear the inherited environment so extensions cannot read secrets from
718        // the parent process (e.g. ANTHROPIC_API_KEY, SSH_AUTH_SOCK, AWS_*).
719        // Only forward a minimal set of safe, non-sensitive variables.
720        // TODO: forward the extension's declared `secret_env` values here once
721        //       secret_env is resolved at spawn time rather than config time.
722        cmd.env_clear();
723        for var in &["PATH", "HOME", "LANG", "TERM", "XDG_RUNTIME_DIR"] {
724            if let Ok(val) = std::env::var(var) {
725                cmd.env(var, val);
726            }
727        }
728
729        cmd.kill_on_drop(true);
730
731        let mut child = cmd
732            .spawn()
733            .map_err(|e| format!("Failed to spawn extension '{}': {}", id, e))?;
734
735        let stdin = child
736            .stdin
737            .take()
738            .ok_or_else(|| format!("No stdin for extension '{}'", id))?;
739        let stdout = child
740            .stdout
741            .take()
742            .ok_or_else(|| format!("No stdout for extension '{}'", id))?;
743        if let Some(stderr) = child.stderr.take() {
744            let extension_id = id.to_string();
745            tokio::spawn(async move {
746                // Bounded stderr forwarding (Task 20): extension-controlled
747                // bytes. Each line is capped, total forwarded bytes are
748                // budgeted; past the budget only byte counts are tracked so
749                // a hostile child can never grow memory or flood the log.
750                const MAX_STDERR_LINE: usize = 8 * 1024;
751                const MAX_STDERR_FORWARD_TOTAL: u64 = 128 * 1024;
752                let mut reader = BufReader::new(stderr);
753                let mut forwarded: u64 = 0;
754                let mut suppressed: u64 = 0;
755                loop {
756                    let mut buf: Vec<u8> = Vec::new();
757                    let mut limited =
758                        tokio::io::AsyncReadExt::take(&mut reader, MAX_STDERR_LINE as u64 + 1);
759                    match tokio::io::AsyncBufReadExt::read_until(&mut limited, b'\n', &mut buf)
760                        .await
761                    {
762                        Ok(0) => break,
763                        Ok(n) => {
764                            if forwarded < MAX_STDERR_FORWARD_TOTAL {
765                                forwarded = forwarded.saturating_add(n as u64);
766                                let truncated = n > MAX_STDERR_LINE;
767                                let end = buf.len().min(MAX_STDERR_LINE);
768                                let line = String::from_utf8_lossy(&buf[..end]);
769                                let line = line.trim_end_matches(['\r', '\n']);
770                                tracing::debug!(
771                                    extension = %extension_id,
772                                    truncated,
773                                    stderr = %line,
774                                );
775                            } else {
776                                suppressed = suppressed.saturating_add(n as u64);
777                            }
778                        }
779                        Err(error) => {
780                            tracing::debug!(
781                                extension = %extension_id,
782                                error = %error,
783                                "Failed to read extension stderr",
784                            );
785                            break;
786                        }
787                    }
788                }
789                if suppressed > 0 {
790                    tracing::debug!(
791                        extension = %extension_id,
792                        suppressed_bytes = suppressed,
793                        "Extension stderr exceeded the forwarding budget; further bytes counted only",
794                    );
795                }
796            });
797        }
798
799        let reader_handle = Self::spawn_reader(stdout, inbox.clone(), id.to_string());
800
801        let stdin_arc = Arc::new(Mutex::new(stdin));
802        // Publish current stdin into the inbox so the reader task can write
803        // JSON-RPC responses for inbound requests (e.g. memory.append).
804        *inbox.inbound_stdin.lock().await = Some(stdin_arc.clone());
805
806        Ok(ProcessState {
807            child,
808            stdin: stdin_arc,
809            reader_handle,
810        })
811    }
812
813    /// Spawn the background reader task that owns `stdout`, demultiplexing
814    /// JSON-RPC responses (by id) and notifications (no id) into the shared
815    /// [`Inbox`]. Returns a `JoinHandle` so callers can `.abort()` it on
816    /// restart or shutdown.
817    fn spawn_reader(
818        stdout: ChildStdout,
819        inbox: Arc<Inbox>,
820        extension_id: String,
821    ) -> JoinHandle<()> {
822        tokio::spawn(async move {
823            let mut reader = BufReader::new(stdout);
824            loop {
825                match Self::read_one_frame(&mut reader, &extension_id).await {
826                    Ok(Some(value)) => {
827                        Self::dispatch_frame(value, &inbox, &extension_id).await;
828                    }
829                    Ok(None) => {
830                        tracing::debug!(
831                            extension = %extension_id,
832                            "Extension stdout closed (EOF); failing pending requests",
833                        );
834                        inbox.fail_all_pending("transport closed: EOF").await;
835                        // Drop all notification subscribers on EOF.
836                        inbox.notification_sinks.lock().await.clear();
837                        return;
838                    }
839                    Err(error) => {
840                        tracing::debug!(
841                            extension = %extension_id,
842                            error = %error,
843                            "Extension transport read error",
844                        );
845                        inbox
846                            .fail_all_pending(&format!("transport error: {}", error))
847                            .await;
848                        inbox.notification_sinks.lock().await.clear();
849                        return;
850                    }
851                }
852            }
853        })
854    }
855
856    /// Read one Content-Length-framed JSON message from `reader`. Returns
857    /// `Ok(None)` on a clean EOF *before* any header bytes are read; any
858    /// other unexpected EOF is reported as `Err`.
859    async fn read_one_frame(
860        reader: &mut BufReader<ChildStdout>,
861        extension_id: &str,
862    ) -> Result<Option<Value>, String> {
863        let mut content_length: Option<usize> = None;
864        let mut saw_any_header = false;
865        loop {
866            let mut header_line = String::new();
867            // Bounded header read: a hostile child streaming bytes without
868            // a newline must fail the 1 KiB header policy, not grow memory.
869            let n = {
870                let mut limited = tokio::io::AsyncReadExt::take(&mut *reader, 1025);
871                limited
872                    .read_line(&mut header_line)
873                    .await
874                    .map_err(|e| format!("Read header error: {}", e))?
875            };
876            if n == 0 {
877                if saw_any_header {
878                    return Err("Unexpected EOF while reading response headers".into());
879                }
880                return Ok(None);
881            }
882            saw_any_header = true;
883            if header_line.len() > 1024 {
884                return Err(format!(
885                    "Extension '{}' header line too long ({} bytes)",
886                    extension_id,
887                    header_line.len()
888                ));
889            }
890            let trimmed = header_line.trim();
891            if trimmed.is_empty() {
892                break;
893            }
894            if let Some((name, value)) = trimmed.split_once(':') {
895                if name.trim().eq_ignore_ascii_case("Content-Length") {
896                    content_length = Some(value.trim().parse().map_err(|_| {
897                        format!("Invalid Content-Length value: {:?}", value.trim())
898                    })?);
899                }
900            }
901        }
902        let content_length = content_length.ok_or_else(|| {
903            format!(
904                "Extension '{}' frame missing Content-Length header",
905                extension_id
906            )
907        })?;
908        const MAX_RESPONSE_SIZE: usize = 4 * 1024 * 1024;
909        if content_length > MAX_RESPONSE_SIZE {
910            return Err(format!(
911                "Extension '{}' frame too large: {} bytes (max {})",
912                extension_id, content_length, MAX_RESPONSE_SIZE
913            ));
914        }
915        let mut buf = vec![0u8; content_length];
916        tokio::io::AsyncReadExt::read_exact(reader, &mut buf)
917            .await
918            .map_err(|e| format!("Read body error: {}", e))?;
919        let value: Value =
920            serde_json::from_slice(&buf).map_err(|e| format!("Parse frame error: {}", e))?;
921        Ok(Some(value))
922    }
923
924    /// Route a parsed JSON-RPC frame to the right consumer:
925    /// - response (`id` numeric, no `method`) → matching pending oneshot
926    /// - request (`id` numeric and `method`) → inbound request handler
927    /// - notification (`method` set, no `id`) → notification subscriber
928    /// - anything else → trace and drop
929    async fn dispatch_frame(value: Value, inbox: &Arc<Inbox>, extension_id: &str) {
930        let id_field = value.get("id");
931        let id_is_present = !matches!(id_field, None | Some(Value::Null));
932        let method_field = value
933            .get("method")
934            .and_then(Value::as_str)
935            .map(str::to_string);
936
937        if id_is_present && method_field.is_some() {
938            // Inbound request from the extension. Spawn a task to handle it
939            // so the reader loop is never blocked on memory I/O or other work.
940            let id = match id_field.and_then(Value::as_u64) {
941                Some(id) => id,
942                None => {
943                    tracing::trace!(
944                        extension = %extension_id,
945                        frame = %value,
946                        "Discarding inbound request with non-numeric id",
947                    );
948                    return;
949                }
950            };
951            let Some(method) = method_field else { return };
952            let params = value.get("params").cloned().unwrap_or(Value::Null);
953            let inbox = inbox.clone();
954            let extension_id = extension_id.to_string();
955            tokio::spawn(async move {
956                let outcome = Self::handle_inbound_request(&inbox, &method, params).await;
957                let payload = match outcome {
958                    Ok(result) => serde_json::json!({
959                        "jsonrpc": "2.0",
960                        "id": id,
961                        "result": result,
962                    }),
963                    Err((code, message)) => serde_json::json!({
964                        "jsonrpc": "2.0",
965                        "id": id,
966                        "error": {"code": code, "message": message},
967                    }),
968                };
969                let stdin_handle = inbox.inbound_stdin.lock().await.clone();
970                if let Some(stdin) = stdin_handle {
971                    let body = match serde_json::to_string(&payload) {
972                        Ok(s) => s,
973                        Err(error) => {
974                            tracing::warn!(
975                                extension = %extension_id,
976                                error = %error,
977                                "Failed to serialize inbound response",
978                            );
979                            return;
980                        }
981                    };
982                    let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
983                    let mut stdin = stdin.lock().await;
984                    if let Err(error) = stdin.write_all(frame.as_bytes()).await {
985                        tracing::warn!(
986                            extension = %extension_id,
987                            error = %error,
988                            "Failed to write inbound response",
989                        );
990                        return;
991                    }
992                    if let Err(error) = stdin.flush().await {
993                        tracing::warn!(
994                            extension = %extension_id,
995                            error = %error,
996                            "Failed to flush inbound response",
997                        );
998                    }
999                } else {
1000                    tracing::warn!(
1001                        extension = %extension_id,
1002                        "No stdin available to reply to inbound request",
1003                    );
1004                }
1005            });
1006            return;
1007        }
1008
1009        if id_is_present {
1010            let id = match id_field.and_then(Value::as_u64) {
1011                Some(id) => id,
1012                None => {
1013                    tracing::trace!(
1014                        extension = %extension_id,
1015                        frame = %value,
1016                        "Discarding frame with non-numeric id",
1017                    );
1018                    return;
1019                }
1020            };
1021            let sender = inbox.pending.lock().await.remove(&id);
1022            match sender {
1023                Some(tx) => {
1024                    let payload = if let Some(err) = value.get("error") {
1025                        let message = err
1026                            .get("message")
1027                            .and_then(Value::as_str)
1028                            .unwrap_or("unknown extension error")
1029                            .to_string();
1030                        Err(format!("Extension error: {}", message))
1031                    } else {
1032                        Ok(value.get("result").cloned().unwrap_or(Value::Null))
1033                    };
1034                    let _ = tx.send(payload);
1035                }
1036                None => {
1037                    tracing::trace!(
1038                        extension = %extension_id,
1039                        id = id,
1040                        "Response with unknown id (no pending request); dropping",
1041                    );
1042                }
1043            }
1044        } else if let Some(method) = value.get("method").and_then(Value::as_str) {
1045            let params = value.get("params").cloned().unwrap_or(Value::Null);
1046            let frame = NotificationFrame {
1047                method: method.to_string(),
1048                params,
1049            };
1050            // Snapshot the live subscribers WITHOUT holding the lock across
1051            // the awaited sends below — a subscriber unsubscribing while we
1052            // backpressure must not deadlock against this dispatch.
1053            let targets: Vec<(usize, mpsc::Sender<NotificationFrame>)> = {
1054                let sinks = inbox.notification_sinks.lock().await;
1055                sinks.to_vec()
1056            };
1057            if targets.is_empty() {
1058                tracing::trace!(
1059                    extension = %extension_id,
1060                    method = %method,
1061                    "Notification with no active subscribers; dropping",
1062                );
1063            } else {
1064                // Bounded fan-out: an un-draining subscriber BACKPRESSURES
1065                // this reader (and, through the stdout pipe, the child)
1066                // instead of growing host memory. `send` only errors when
1067                // the receiver is gone, so failure == dead subscriber.
1068                let mut dead = Vec::new();
1069                for (id, tx) in &targets {
1070                    if tx.send(frame.clone()).await.is_err() {
1071                        dead.push(*id);
1072                    }
1073                }
1074                if !dead.is_empty() {
1075                    let mut sinks = inbox.notification_sinks.lock().await;
1076                    sinks.retain(|(id, _)| !dead.contains(id));
1077                }
1078            }
1079        } else {
1080            tracing::trace!(
1081                extension = %extension_id,
1082                frame = %value,
1083                "Unrecognized frame; dropping",
1084            );
1085        }
1086    }
1087
1088    pub fn restart_count(&self) -> usize {
1089        // Lifetime total — reporting/tests expect total restarts, not the
1090        // consecutive-failure budget counter (which resets on success).
1091        self.total_restarts.load(Ordering::Relaxed)
1092    }
1093
1094    /// Public for tests: set the permission set used by inbound RPC handlers
1095    /// (e.g. memory.append). Called by the manager after manifest validation.
1096    pub async fn set_permissions(&self, perms: crate::extensions::permissions::PermissionSet) {
1097        *self.inbox.permissions.write().await = Some(perms);
1098    }
1099
1100    /// Handle a JSON-RPC request initiated by the extension.
1101    ///
1102    /// Returns `Ok(result_value)` on success or `Err((code, message))` for a
1103    /// JSON-RPC error response. Currently routes:
1104    /// - `memory.append` (requires `memory.write`)
1105    /// - `memory.query`  (requires `memory.read`)
1106    ///   All other methods return -32601 (method not found).
1107    #[allow(clippy::doc_lazy_continuation)]
1108    async fn handle_inbound_request(
1109        inbox: &Arc<Inbox>,
1110        method: &str,
1111        params: Value,
1112    ) -> Result<Value, (i32, String)> {
1113        use crate::extensions::permissions::Permission;
1114        use crate::memory::store::{self, MemoryQuery};
1115
1116        match method {
1117            "memory.append" => {
1118                Self::require_permission(inbox, Permission::MemoryWrite, "memory.write").await?;
1119                let namespace = Self::param_str(&params, "namespace")?;
1120                Self::require_namespace_matches(inbox, &namespace).await?;
1121                let content = Self::param_str(&params, "content")?;
1122                let tags = match params.get("tags") {
1123                    None | Some(Value::Null) => Vec::new(),
1124                    Some(Value::Array(arr)) => {
1125                        let mut out = Vec::with_capacity(arr.len());
1126                        for v in arr {
1127                            match v.as_str() {
1128                                Some(s) => out.push(s.to_string()),
1129                                None => {
1130                                    return Err((
1131                                        -32602,
1132                                        "tags must be an array of strings".to_string(),
1133                                    ))
1134                                }
1135                            }
1136                        }
1137                        out
1138                    }
1139                    _ => return Err((-32602, "tags must be an array of strings".to_string())),
1140                };
1141                let meta = match params.get("meta") {
1142                    None | Some(Value::Null) => None,
1143                    Some(v) => Some(v.clone()),
1144                };
1145                let record = store::new_record(namespace, content, tags, meta);
1146                let timestamp_ms = record.timestamp_ms;
1147                store::append(&record).map_err(|e| (-32000, e.to_string()))?;
1148                Ok(serde_json::json!({"ok": true, "timestamp_ms": timestamp_ms}))
1149            }
1150            "memory.query" => {
1151                Self::require_permission(inbox, Permission::MemoryRead, "memory.read").await?;
1152                let namespace = Self::param_str(&params, "namespace")?;
1153                Self::require_namespace_matches(inbox, &namespace).await?;
1154                let q = MemoryQuery {
1155                    content_contains: params
1156                        .get("content_contains")
1157                        .and_then(Value::as_str)
1158                        .map(str::to_string),
1159                    tag_prefix: params
1160                        .get("tag_prefix")
1161                        .and_then(Value::as_str)
1162                        .map(str::to_string),
1163                    since_ms: params.get("since_ms").and_then(Value::as_u64),
1164                    until_ms: params.get("until_ms").and_then(Value::as_u64),
1165                    limit: params
1166                        .get("limit")
1167                        .and_then(Value::as_u64)
1168                        .map(|n| n as usize),
1169                };
1170                let records = store::query(&namespace, &q).map_err(|e| (-32000, e.to_string()))?;
1171                Ok(serde_json::json!({"records": records}))
1172            }
1173            "config.get" => {
1174                let key = Self::param_str(&params, "key")?;
1175                Self::validate_config_key(&key)?;
1176                let value =
1177                    crate::extensions::config_store::read_plugin_config(&inbox.extension_id, &key);
1178                Ok(serde_json::json!({"value": value}))
1179            }
1180            "config.set" => {
1181                Self::require_permission(inbox, Permission::ConfigWrite, "config.write").await?;
1182                let key = Self::param_str(&params, "key")?;
1183                Self::validate_config_key(&key)?;
1184                let value = Self::param_str(&params, "value")?;
1185                crate::extensions::config_store::write_plugin_config(
1186                    &inbox.extension_id,
1187                    &key,
1188                    &value,
1189                )
1190                .map_err(|e| (-32000, e.to_string()))?;
1191                Ok(serde_json::json!({"ok": true}))
1192            }
1193            "config.subscribe" => {
1194                Self::require_permission(inbox, Permission::ConfigSubscribe, "config.subscribe")
1195                    .await?;
1196                // The long-lived watcher-to-notification bridge is wired at the manager/UI layer.
1197                // This phase exposes the authorized protocol ACK so plugins can opt in without
1198                // blocking initialize; direct store watchers are unit-tested in config_store.
1199                Ok(serde_json::json!({"ok": true}))
1200            }
1201            other => Err((-32601, format!("method not found: {other}"))),
1202        }
1203    }
1204
1205    async fn require_permission(
1206        inbox: &Arc<Inbox>,
1207        perm: crate::extensions::permissions::Permission,
1208        wire: &str,
1209    ) -> Result<(), (i32, String)> {
1210        let guard = inbox.permissions.read().await;
1211        match guard.as_ref() {
1212            Some(set) if set.has(perm) => Ok(()),
1213            _ => Err((-32602, format!("permission denied: {wire} required"))),
1214        }
1215    }
1216
1217    async fn require_namespace_matches(
1218        inbox: &Arc<Inbox>,
1219        namespace: &str,
1220    ) -> Result<(), (i32, String)> {
1221        if namespace == inbox.extension_id {
1222            Ok(())
1223        } else {
1224            Err((
1225                -32602,
1226                format!(
1227                    "namespace must equal extension id '{}' (got '{}')",
1228                    inbox.extension_id, namespace
1229                ),
1230            ))
1231        }
1232    }
1233
1234    fn param_str(params: &Value, name: &str) -> Result<String, (i32, String)> {
1235        params
1236            .get(name)
1237            .and_then(Value::as_str)
1238            .map(str::to_string)
1239            .ok_or_else(|| (-32602, format!("missing or invalid '{name}' parameter")))
1240    }
1241
1242    fn validate_config_key(key: &str) -> Result<(), (i32, String)> {
1243        let trimmed = key.trim();
1244        if trimmed.is_empty() {
1245            return Err((-32602, "config key must be non-empty".to_string()));
1246        }
1247        if trimmed.contains('.') || trimmed.contains('/') || trimmed.contains(' ') {
1248            return Err((
1249                -32602,
1250                "config key must not contain dots, slashes, or spaces".to_string(),
1251            ));
1252        }
1253        Ok(())
1254    }
1255
1256    pub async fn initialize(
1257        &self,
1258        plugin_root: Option<PathBuf>,
1259        config: Value,
1260    ) -> Result<InitializeCapabilitiesResult, String> {
1261        // Retain only in memory: restart handshakes must receive the same
1262        // resolved config, while secrets never enter env, logs, or storage.
1263        *self.initialize_config.lock().await = config.clone();
1264        let params = InitializeParams {
1265            synaps_version: env!("CARGO_PKG_VERSION"),
1266            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1267            plugin_id: self.id.clone(),
1268            plugin_root: plugin_root
1269                .or_else(|| self.cwd.clone())
1270                .map(|path| path.to_string_lossy().to_string()),
1271            config,
1272        };
1273        let value = self
1274            .call_no_restart(
1275                "initialize",
1276                serde_json::to_value(params).map_err(|e| e.to_string())?,
1277            )
1278            .await?;
1279        Self::parse_initialize_result(&self.id, value)
1280    }
1281
1282    fn parse_initialize_result(
1283        id: &str,
1284        value: Value,
1285    ) -> Result<InitializeCapabilitiesResult, String> {
1286        let result: InitializeResult = serde_json::from_value(value)
1287            .map_err(|e| format!("Invalid initialize response from extension '{}': {}", id, e))?;
1288        if result.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
1289            return Err(format!(
1290                "Extension '{}' initialize returned unsupported protocol_version {} (supported: {})",
1291                id, result.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
1292            ));
1293        }
1294        Self::validate_registered_tool_specs(id, &result.capabilities.tools)?;
1295        Self::validate_registered_provider_specs(id, &result.capabilities.providers)?;
1296        Ok(InitializeCapabilitiesResult {
1297            tools: result.capabilities.tools,
1298            providers: result.capabilities.providers,
1299            capabilities: result.capabilities.capabilities,
1300        })
1301    }
1302
1303    fn validate_registered_tool_specs(
1304        id: &str,
1305        tools: &[RegisteredExtensionToolSpec],
1306    ) -> Result<(), String> {
1307        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1308        let mut names = HashSet::new();
1309        for tool in tools {
1310            let name = tool.name.trim();
1311            if let Err(err) = validate_id_segment(name) {
1312                return Err(match err {
1313                    IdValidationError::Empty => format!(
1314                        "Extension '{}' registered a tool with an empty tool name",
1315                        id
1316                    ),
1317                    IdValidationError::ContainsReserved { ch } => format!(
1318                        "Extension '{}' registered tool '{}' with invalid tool name: '{}' is reserved",
1319                        id, name, ch
1320                    ),
1321                    IdValidationError::TooLong { len, max } => format!(
1322                        "Extension '{}' registered tool '{}' with invalid tool name: must be at most {} chars (got {})",
1323                        id, name, max, len
1324                    ),
1325                    IdValidationError::ContainsWhitespace => format!(
1326                        "Extension '{}' registered tool '{}' with invalid tool name: must not contain whitespace",
1327                        id, name
1328                    ),
1329                    IdValidationError::ContainsControl { ch } => format!(
1330                        "Extension '{}' registered tool '{}' with invalid tool name: contains control character U+{:04X}",
1331                        id, name, ch as u32
1332                    ),
1333                });
1334            }
1335            if !names.insert(name.to_string()) {
1336                return Err(format!(
1337                    "Extension '{}' registered duplicate tool name '{}'",
1338                    id, name
1339                ));
1340            }
1341            if tool.description.trim().is_empty() {
1342                return Err(format!(
1343                    "Extension '{}' registered tool '{}' with an empty description",
1344                    id, name,
1345                ));
1346            }
1347            if !tool.input_schema.is_object() {
1348                return Err(format!(
1349                    "Extension '{}' registered tool '{}' with invalid input_schema: input_schema must be a JSON object",
1350                    id, name,
1351                ));
1352            }
1353        }
1354        Ok(())
1355    }
1356
1357    fn validate_registered_provider_specs(
1358        id: &str,
1359        providers: &[RegisteredProviderSpec],
1360    ) -> Result<(), String> {
1361        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1362        for provider in providers {
1363            let provider_id = provider.id.trim();
1364            match validate_id_segment(provider_id) {
1365                Ok(()) => {
1366                    if !Self::is_safe_provider_id(provider_id) {
1367                        return Err(format!(
1368                            "Extension '{}' registered provider '{}' with invalid provider id",
1369                            id, provider_id
1370                        ));
1371                    }
1372                }
1373                Err(IdValidationError::Empty) => {
1374                    return Err(format!(
1375                        "Extension '{}' registered provider with empty provider id",
1376                        id
1377                    ));
1378                }
1379                Err(err) => {
1380                    return Err(format!(
1381                        "Extension '{}' registered provider '{}' with invalid provider id: {}",
1382                        id, provider_id, err
1383                    ));
1384                }
1385            }
1386            if provider.display_name.trim().is_empty() {
1387                return Err(format!(
1388                    "Extension '{}' registered provider '{}' with empty display_name",
1389                    id, provider_id,
1390                ));
1391            }
1392            if provider.description.trim().is_empty() {
1393                return Err(format!(
1394                    "Extension '{}' registered provider '{}' with empty description",
1395                    id, provider_id,
1396                ));
1397            }
1398            if provider.models.is_empty() {
1399                return Err(format!(
1400                    "Extension '{}' registered provider '{}' must declare at least one model",
1401                    id, provider_id,
1402                ));
1403            }
1404            let mut model_ids = HashSet::new();
1405            for model in &provider.models {
1406                let model_id = model.id.trim();
1407                if let Err(err) = validate_id_segment(model_id) {
1408                    return Err(match err {
1409                        IdValidationError::Empty => format!(
1410                            "Extension '{}' registered provider '{}' with empty model id",
1411                            id, provider_id
1412                        ),
1413                        IdValidationError::ContainsReserved { ch } => format!(
1414                            "Extension '{}' registered provider '{}' with invalid model id '{}': '{}' is reserved",
1415                            id, provider_id, model_id, ch
1416                        ),
1417                        IdValidationError::TooLong { len, max } => format!(
1418                            "Extension '{}' registered provider '{}' with invalid model id '{}': must be at most {} chars (got {})",
1419                            id, provider_id, model_id, max, len
1420                        ),
1421                        IdValidationError::ContainsWhitespace => format!(
1422                            "Extension '{}' registered provider '{}' with invalid model id '{}': must not contain whitespace",
1423                            id, provider_id, model_id
1424                        ),
1425                        IdValidationError::ContainsControl { ch } => format!(
1426                            "Extension '{}' registered provider '{}' with invalid model id '{}': contains control character U+{:04X}",
1427                            id, provider_id, model_id, ch as u32
1428                        ),
1429                    });
1430                }
1431                if !model_ids.insert(model_id.to_string()) {
1432                    return Err(format!(
1433                        "Extension '{}' registered provider '{}' with duplicate model id '{}'",
1434                        id, provider_id, model_id,
1435                    ));
1436                }
1437            }
1438            if let Some(config_schema) = &provider.config_schema {
1439                if !config_schema.is_object() {
1440                    return Err(format!(
1441                        "Extension '{}' registered provider '{}' with invalid config_schema: config_schema must be a JSON object",
1442                        id, provider_id,
1443                    ));
1444                }
1445            }
1446        }
1447        Ok(())
1448    }
1449
1450    fn is_safe_provider_id(id: &str) -> bool {
1451        !id.is_empty()
1452            && !id.contains(':')
1453            && id
1454                .chars()
1455                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1456    }
1457
1458    #[doc(hidden)]
1459    pub async fn initialize_for_test(&self, plugin_root: Option<PathBuf>) -> Result<(), String> {
1460        self.initialize(plugin_root, Value::Object(Default::default()))
1461            .await
1462            .map(|_| ())
1463    }
1464
1465    async fn restart_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1466        let attempted = self.restart_count.fetch_add(1, Ordering::Relaxed) + 1;
1467        self.total_restarts.fetch_add(1, Ordering::Relaxed);
1468        let max_attempts = self.restart_policy.max_attempts;
1469        if attempted > max_attempts as usize {
1470            *state = None;
1471            return Err(format!(
1472                "Extension '{}' exceeded restart limit ({})",
1473                self.id, max_attempts,
1474            ));
1475        }
1476
1477        if let Some(old) = state.take() {
1478            old.reader_handle.abort();
1479            let mut child = old.child;
1480            let _ = child.kill().await;
1481        }
1482        // Drain any stale pending entries before reusing the inbox.
1483        self.inbox
1484            .fail_all_pending("transport closed: process restarting")
1485            .await;
1486
1487        let delay = self
1488            .restart_policy
1489            .delay_for_attempt(attempted as u32)
1490            .unwrap_or_default();
1491
1492        tracing::warn!(
1493            extension = %self.id,
1494            attempt = attempted,
1495            max_attempts = max_attempts,
1496            delay_ms = delay.as_millis() as u64,
1497            "Restarting extension process after transport failure",
1498        );
1499
1500        if !delay.is_zero() {
1501            tokio::time::sleep(delay).await;
1502        }
1503
1504        *state = Some(
1505            Self::spawn_state(
1506                &self.id,
1507                &self.command,
1508                &self.args,
1509                self.cwd.as_ref(),
1510                self.inbox.clone(),
1511            )
1512            .await?,
1513        );
1514        // Reset closed flag now that we have a fresh transport
1515        self.inbox
1516            .closed
1517            .store(false, std::sync::atomic::Ordering::Release);
1518        self.initialize_locked(state).await?;
1519        // NOTE: the consecutive-failure counter is NOT reset here. A
1520        // successful handshake isn't proof of recovery — a crash-looping
1521        // extension can initialize fine and die on the next request
1522        // (always_exit fixture). The reset happens in call_inner once the
1523        // restarted process actually serves a call (preserves H-8 intent:
1524        // transient failures hours apart don't accumulate toward disable).
1525        Ok(())
1526    }
1527
1528    async fn initialize_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1529        let config = self.initialize_config.lock().await.clone();
1530        let params = InitializeParams {
1531            synaps_version: env!("CARGO_PKG_VERSION"),
1532            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1533            plugin_id: self.id.clone(),
1534            plugin_root: self
1535                .cwd
1536                .clone()
1537                .map(|path| path.to_string_lossy().to_string()),
1538            config,
1539        };
1540        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1541        let value = tokio::time::timeout(
1542            std::time::Duration::from_secs(10),
1543            self.call_once_locked(
1544                state.as_mut().expect("state should exist for initialize"),
1545                "initialize",
1546                serde_json::to_value(params).map_err(|e| e.to_string())?,
1547                id,
1548            ),
1549        )
1550        .await
1551        .map_err(|_| format!("Extension '{}' initialize timed out after 10s", self.id))??;
1552        Self::parse_initialize_result(&self.id, value).map(|_| ())
1553    }
1554
1555    /// Send a single JSON-RPC request and await the matching response,
1556    /// using the shared inbox for response delivery. The reader task runs
1557    /// concurrently and may dispatch interleaved notifications.
1558    async fn call_once_locked(
1559        &self,
1560        state: &mut ProcessState,
1561        method: &str,
1562        params: Value,
1563        id: u64,
1564    ) -> Result<Value, String> {
1565        let body = serde_json::to_string(&JsonRpcRequest {
1566            jsonrpc: "2.0",
1567            method: method.to_string(),
1568            params,
1569            id,
1570        })
1571        .map_err(|e| format!("Serialize error: {}", e))?;
1572
1573        let (tx, rx) = oneshot::channel::<Result<Value, String>>();
1574        // Check if inbox is closed before registering — if the reader already
1575        // exited, no one will ever send a response on this channel.
1576        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1577            return Err("transport closed: inbox is shut down".to_string());
1578        }
1579
1580        // Register pending BEFORE writing so the reader can route a fast
1581        // response without racing against the insert.
1582        self.inbox.pending.lock().await.insert(id, tx);
1583
1584        // Double-check: if closed was set between our check and the insert,
1585        // remove the entry and fail immediately.
1586        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1587            self.inbox.pending.lock().await.remove(&id);
1588            return Err("transport closed: inbox shut down during registration".to_string());
1589        }
1590
1591        let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
1592        let write_result = {
1593            let mut stdin = state.stdin.lock().await;
1594            match stdin.write_all(frame.as_bytes()).await {
1595                Ok(()) => stdin.flush().await,
1596                Err(e) => Err(e),
1597            }
1598        };
1599        if let Err(e) = write_result {
1600            // Make sure we don't leak the pending entry if the write fails.
1601            self.inbox.pending.lock().await.remove(&id);
1602            return Err(format!("Write error: {}", e));
1603        }
1604
1605        match rx.await {
1606            Ok(payload) => payload,
1607            Err(_) => {
1608                // Sender was dropped without sending — typically because the
1609                // reader task observed EOF/error after we registered. The
1610                // reader normally sends an Err first; this branch is a
1611                // belt-and-braces fallback.
1612                self.inbox.pending.lock().await.remove(&id);
1613                Err("transport closed: response channel dropped".to_string())
1614            }
1615        }
1616    }
1617
1618    async fn call_no_restart(&self, method: &str, params: Value) -> Result<Value, String> {
1619        let _call_guard = self.call_lock.lock().await;
1620        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1621        let mut state_guard = self.state.lock().await;
1622        if state_guard.is_none() {
1623            *state_guard = Some(
1624                Self::spawn_state(
1625                    &self.id,
1626                    &self.command,
1627                    &self.args,
1628                    self.cwd.as_ref(),
1629                    self.inbox.clone(),
1630                )
1631                .await?,
1632            );
1633        }
1634        self.call_once_locked(
1635            state_guard.as_mut().expect("state should exist"),
1636            method,
1637            params,
1638            id,
1639        )
1640        .await
1641    }
1642
1643    /// Send one request only if the current child is still present. Teardown
1644    /// must never resurrect a child that concurrent cancellation already
1645    /// removed, so unlike [`Self::call`] this bypasses all spawn/restart paths.
1646    async fn call_no_spawn(&self, method: &str, params: Value) -> Result<Value, String> {
1647        let _call_guard = self.call_lock.lock().await;
1648        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1649        let mut state_guard = self.state.lock().await;
1650        let Some(state) = state_guard.as_mut() else {
1651            return Err(format!(
1652                "Extension '{}' process is not running; '{}' refused (no respawn)",
1653                self.id, method
1654            ));
1655        };
1656        self.call_once_locked(state, method, params, id).await
1657    }
1658
1659    /// Task 20 lease path: one `tool.call` that NEVER (re)spawns. A missing
1660    /// process state fails typed instead of resurrecting a terminated
1661    /// child, and the restart machinery is bypassed entirely — a lease
1662    /// child that dies is a transport error, never a silent new process
1663    /// whose declarations were not re-validated.
1664    pub(crate) async fn call_tool_once_no_spawn(
1665        &self,
1666        name: &str,
1667        input: Value,
1668    ) -> Result<Value, String> {
1669        let fut = async {
1670            let _call_guard = self.call_lock.lock().await;
1671            let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1672            // Take the running state out while awaiting the response so a
1673            // concurrent cancellation can observe an empty slot and still
1674            // fail the shared inbox pending map. The state is restored only
1675            // if cancellation did not close the inbox.
1676            let mut state = {
1677                let mut state_guard = self.state.lock().await;
1678                state_guard.take().ok_or_else(|| {
1679                    format!(
1680                        "Extension '{}' process is not running; leased call refused (no respawn)",
1681                        self.id
1682                    )
1683                })?
1684            };
1685            let result = self
1686                .call_once_locked(
1687                    &mut state,
1688                    "tool.call",
1689                    serde_json::json!({"name": name, "input": input}),
1690                    id,
1691                )
1692                .await;
1693            if !self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1694                *self.state.lock().await = Some(state);
1695            } else {
1696                state.reader_handle.abort();
1697                let _ = state.child.kill().await;
1698            }
1699            result
1700        };
1701        match tokio::time::timeout(std::time::Duration::from_secs(120), fut).await {
1702            Ok(result) => result,
1703            Err(_) => Err(format!(
1704                "Extension '{}' leased tool.call timed out after 120s",
1705                self.id
1706            )),
1707        }
1708    }
1709
1710    async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
1711        let timeout_secs = if method == "tool.call" { 120 } else { 30 };
1712        let id_str = self.id.clone();
1713        let method_str = method.to_string();
1714
1715        let result = tokio::time::timeout(
1716            std::time::Duration::from_secs(timeout_secs),
1717            self.call_inner(method, params),
1718        )
1719        .await;
1720
1721        match result {
1722            Ok(inner) => inner,
1723            Err(_) => Err(format!(
1724                "Extension '{}' method '{}' timed out after {}s",
1725                id_str, method_str, timeout_secs
1726            )),
1727        }
1728    }
1729
1730    async fn call_inner(&self, method: &str, params: Value) -> Result<Value, String> {
1731        let _call_guard = self.call_lock.lock().await;
1732        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1733        let mut state_guard = self.state.lock().await;
1734        if state_guard.is_none() {
1735            self.restart_locked(&mut state_guard).await?;
1736        }
1737
1738        let result = self
1739            .call_once_locked(
1740                state_guard
1741                    .as_mut()
1742                    .expect("state should exist after restart"),
1743                method,
1744                params.clone(),
1745                id,
1746            )
1747            .await;
1748
1749        match result {
1750            Ok(value) => {
1751                // Process served a real call — it has genuinely recovered.
1752                // Reset the consecutive-failure budget (H-8) so transient
1753                // crashes hours apart don't accumulate toward disable.
1754                self.restart_count.store(0, Ordering::Relaxed);
1755                Ok(value)
1756            }
1757            Err(first_error) => {
1758                self.restart_locked(&mut state_guard).await?;
1759                let retry_id = self.next_id.fetch_add(1, Ordering::Relaxed);
1760                self.call_once_locked(
1761                    state_guard
1762                        .as_mut()
1763                        .expect("state should exist after restart"),
1764                    method,
1765                    params,
1766                    retry_id,
1767                )
1768                .await
1769                .inspect(|_| {
1770                    // Retry succeeded post-restart — also a real recovery.
1771                    self.restart_count.store(0, Ordering::Relaxed);
1772                })
1773                .map_err(|retry_error| {
1774                    format!(
1775                        "{}; retry after restart failed: {}",
1776                        first_error, retry_error
1777                    )
1778                })
1779            }
1780        }
1781    }
1782
1783    /// Subscribe to JSON-RPC notifications emitted by the extension.
1784    ///
1785    /// Returns `(subscription_id, receiver)`. The id must be passed back to
1786    /// [`unsubscribe_notifications`] to cancel this subscription specifically
1787    /// — concurrent subscribers (e.g. background widget watchers running
1788    /// alongside `command.invoke` / `provider.stream`) coexist and each
1789    /// receives every notification frame.
1790    ///
1791    /// The receiver yields every notification frame (no `id`, has `method`)
1792    /// the extension sends until either:
1793    /// - the receiver is dropped (its slot is pruned on the next dispatch),
1794    /// - [`unsubscribe_notifications`] is called with this id, or
1795    /// - the reader observes EOF or a transport error (all subscribers are
1796    ///   dropped together).
1797    ///
1798    /// Internal API: exposed publicly with `#[doc(hidden)]` only so
1799    /// integration tests can exercise the bidirectional transport.
1800    #[doc(hidden)]
1801    pub async fn subscribe_notifications(&self) -> (usize, mpsc::Receiver<NotificationFrame>) {
1802        let (tx, rx) = mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
1803        let id = self
1804            .inbox
1805            .next_sink_id
1806            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1807        let mut sinks = self.inbox.notification_sinks.lock().await;
1808        // Opportunistically prune any subscribers whose receivers have been
1809        // dropped, so the Vec doesn't grow unboundedly across long-lived
1810        // extensions if callers forget to unsubscribe.
1811        sinks.retain(|(_, tx)| !tx.is_closed());
1812        sinks.push((id, tx));
1813        (id, rx)
1814    }
1815
1816    /// Drop the notification subscription with the given id, if any.
1817    /// Unknown ids are silently ignored (idempotent — safe to call on
1818    /// belt-and-braces timeout paths even after the inner future already
1819    /// unsubscribed).
1820    #[doc(hidden)]
1821    pub async fn unsubscribe_notifications(&self, id: usize) {
1822        let mut sinks = self.inbox.notification_sinks.lock().await;
1823        sinks.retain(|(sub_id, tx)| *sub_id != id && !tx.is_closed());
1824    }
1825
1826    /// Forward one notification frame received during `command.invoke`.
1827    ///
1828    /// Routes:
1829    /// - `command.output` whose `request_id` matches → sink as `Output(event)`
1830    ///   (other request_ids are ignored — concurrent invocations must use distinct ids
1831    ///   and may overlap in the same `subscribe_notifications` channel; mismatched
1832    ///   frames are dropped here intentionally).
1833    /// - `task.start|update|log|done` → sink as `Task(event)` regardless of request_id.
1834    /// - Anything else → logged at trace and dropped.
1835    ///
1836    /// CP-11 fix-3: the sink is BOUNDED
1837    /// ([`crate::extensions::invoke_output::InvokeEventSink`]) and its
1838    /// send is AWAITED — a hostile `command.output` flood backpressures
1839    /// this loop (and transitively the bounded notification queue and the
1840    /// sidecar's stdout pipe) instead of parking aggregate bytes in an
1841    /// unbounded queue. Liveness relies on the eagerly concurrent
1842    /// collector on the other end (see `invoke_output` module docs); if
1843    /// that consumer is gone, the send errs and `sink_open` latches false.
1844    pub(crate) async fn forward_invoke_command_frame(
1845        extension_id: &str,
1846        request_id: &str,
1847        sink: &crate::extensions::invoke_output::InvokeEventSink,
1848        sink_open: &mut bool,
1849        frame: NotificationFrame,
1850    ) -> bool {
1851        use crate::extensions::commands::parse_command_output;
1852        use crate::extensions::runtime::InvokeCommandEvent;
1853        use crate::extensions::tasks::{is_task_method, parse_task_event};
1854
1855        let mut saw_done = false;
1856        if frame.method == "command.output" {
1857            match parse_command_output(&frame.params) {
1858                Ok(parsed) if parsed.request_id == request_id => {
1859                    if matches!(
1860                        parsed.event,
1861                        crate::extensions::commands::CommandOutputEvent::Done
1862                    ) {
1863                        saw_done = true;
1864                    }
1865                    if *sink_open
1866                        && sink
1867                            .send(InvokeCommandEvent::Output(parsed.event))
1868                            .await
1869                            .is_err()
1870                    {
1871                        *sink_open = false;
1872                    }
1873                }
1874                Ok(_) => {
1875                    // Different request_id — ignore (logged at trace for diagnostics).
1876                    tracing::trace!(
1877                        extension = %extension_id,
1878                        "Ignoring command.output for unrelated request_id",
1879                    );
1880                }
1881                Err(error) => {
1882                    tracing::warn!(
1883                        extension = %extension_id,
1884                        error = %error,
1885                        params = %frame.params,
1886                        "Skipping malformed command.output notification",
1887                    );
1888                }
1889            }
1890        } else if is_task_method(&frame.method) {
1891            match parse_task_event(&frame.method, &frame.params) {
1892                Ok(event) => {
1893                    if *sink_open && sink.send(InvokeCommandEvent::Task(event)).await.is_err() {
1894                        *sink_open = false;
1895                    }
1896                }
1897                Err(error) => {
1898                    tracing::warn!(
1899                        extension = %extension_id,
1900                        method = %frame.method,
1901                        error = %error,
1902                        params = %frame.params,
1903                        "Skipping malformed task notification",
1904                    );
1905                }
1906            }
1907        } else {
1908            tracing::trace!(
1909                extension = %extension_id,
1910                method = %frame.method,
1911                "Ignoring non-command/task notification during command.invoke",
1912            );
1913        }
1914        saw_done
1915    }
1916
1917    /// Forward one notification frame received during `provider.stream`.
1918    ///
1919    /// - Frames whose method is not `provider.stream.event` are ignored (logged at trace).
1920    /// - Malformed event params are logged at warn and skipped (do not abort the call).
1921    /// - The sink is BOUNDED ([`PROVIDER_EVENT_QUEUE_CAPACITY`]): a slow
1922    ///   downstream backpressures this loop (and transitively the sidecar)
1923    ///   instead of retaining hostile flood events in host memory.
1924    /// - If the caller's sink has been closed, sets `sink_open = false` and stops forwarding,
1925    ///   but the in-flight request is still allowed to complete.
1926    async fn forward_provider_stream_frame(
1927        extension_id: &str,
1928        sink: &mpsc::Sender<ProviderStreamEvent>,
1929        sink_open: &mut bool,
1930        frame: NotificationFrame,
1931    ) {
1932        if frame.method != "provider.stream.event" {
1933            tracing::trace!(
1934                extension = %extension_id,
1935                method = %frame.method,
1936                "Ignoring non-stream notification during provider.stream",
1937            );
1938            return;
1939        }
1940        match parse_provider_stream_event(&frame.params) {
1941            Ok(event) => {
1942                if *sink_open && sink.send(event).await.is_err() {
1943                    *sink_open = false;
1944                }
1945            }
1946            Err(error) => {
1947                tracing::warn!(
1948                    extension = %extension_id,
1949                    error = %error,
1950                    params = %frame.params,
1951                    "Skipping malformed provider.stream.event notification",
1952                );
1953            }
1954        }
1955    }
1956}
1957
1958#[async_trait]
1959impl ExtensionHandler for ProcessExtension {
1960    fn id(&self) -> &str {
1961        &self.id
1962    }
1963
1964    async fn call_tool(&self, name: &str, input: Value) -> Result<Value, String> {
1965        self.call(
1966            "tool.call",
1967            serde_json::json!({
1968                "name": name,
1969                "input": input,
1970            }),
1971        )
1972        .await
1973    }
1974
1975    async fn provider_complete(
1976        &self,
1977        params: ProviderCompleteParams,
1978    ) -> Result<ProviderCompleteResult, String> {
1979        let value = tokio::time::timeout(
1980            std::time::Duration::from_secs(60),
1981            self.call(
1982                "provider.complete",
1983                serde_json::to_value(params).map_err(|e| e.to_string())?,
1984            ),
1985        )
1986        .await
1987        .map_err(|_| format!("Extension '{}' provider.complete timed out", self.id))??;
1988        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
1989            format!(
1990                "Invalid provider.complete response from extension '{}': {}",
1991                self.id, e
1992            )
1993        })?;
1994        if result.content.is_empty() {
1995            return Err(format!(
1996                "Extension '{}' provider.complete returned empty content",
1997                self.id
1998            ));
1999        }
2000        Ok(result)
2001    }
2002
2003    async fn provider_stream(
2004        &self,
2005        params: ProviderCompleteParams,
2006        sink: tokio::sync::mpsc::Sender<ProviderStreamEvent>,
2007    ) -> Result<ProviderCompleteResult, String> {
2008        // Subscribe BEFORE issuing the request so we don't miss early
2009        // notifications that may arrive before `call(...)` even starts polling.
2010        let (sub_id, mut rx) = self.subscribe_notifications().await;
2011        let params_value = serde_json::to_value(params).map_err(|e| e.to_string())?;
2012
2013        let extension_id = self.id.clone();
2014        let stream_future = async {
2015            let mut call_fut = Box::pin(self.call("provider.stream", params_value));
2016            let mut sink_open = true;
2017            let response = loop {
2018                tokio::select! {
2019                    response = &mut call_fut => break response,
2020                    Some(frame) = rx.recv() => {
2021                        Self::forward_provider_stream_frame(
2022                            &extension_id, &sink, &mut sink_open, frame,
2023                        ).await;
2024                    }
2025                }
2026            };
2027            // Response received: drop our subscription so this receiver yields
2028            // `None` once buffered frames are drained, then flush any
2029            // remaining notifications before returning. Other concurrent
2030            // subscribers (if any) are untouched.
2031            self.unsubscribe_notifications(sub_id).await;
2032            while let Some(frame) = rx.recv().await {
2033                Self::forward_provider_stream_frame(&extension_id, &sink, &mut sink_open, frame)
2034                    .await;
2035            }
2036            response
2037        };
2038
2039        let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), stream_future).await;
2040
2041        // Belt-and-braces: ensure our subscription is cleared on timeout too.
2042        // Idempotent if the inner future already unsubscribed.
2043        self.unsubscribe_notifications(sub_id).await;
2044
2045        let value =
2046            outcome.map_err(|_| format!("Extension '{}' provider.stream timed out", self.id))??;
2047
2048        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
2049            format!(
2050                "Invalid provider.stream response from extension '{}': {}",
2051                self.id, e
2052            )
2053        })?;
2054        // NOTE: empty `content` is permitted for streaming — output may have
2055        // been delivered entirely via TextDelta notifications.
2056        Ok(result)
2057    }
2058
2059    async fn invoke_command(
2060        &self,
2061        command: &str,
2062        args: Vec<String>,
2063        request_id: &str,
2064        sink: crate::extensions::invoke_output::InvokeEventSink,
2065    ) -> Result<Value, String> {
2066        // Subscribe before issuing the request so we don't miss early events.
2067        let (sub_id, mut rx) = self.subscribe_notifications().await;
2068        let params = serde_json::json!({
2069            "command": command,
2070            "args": args,
2071            "request_id": request_id,
2072        });
2073
2074        let extension_id = self.id.clone();
2075        let request_id_owned = request_id.to_string();
2076        let invoke_future = async {
2077            let mut call_fut = Box::pin(self.call("command.invoke", params));
2078            let mut sink_open = true;
2079            let response = loop {
2080                tokio::select! {
2081                    response = &mut call_fut => break response,
2082                    Some(frame) = rx.recv() => {
2083                        // The awaited bounded send paces a flood; while it
2084                        // waits, `call_fut` is simply not polled — safe
2085                        // because the eagerly concurrent collector always
2086                        // drains (see invoke_output module docs), and if it
2087                        // is gone the send errs immediately.
2088                        let _ = Self::forward_invoke_command_frame(
2089                            &extension_id, &request_id_owned, &sink, &mut sink_open, frame,
2090                        ).await;
2091                    }
2092                }
2093            };
2094            // Drain any notifications already buffered after the response lands, but
2095            // do not wait for the subscriber channel to close (that would deadlock
2096            // while this invocation still owns `rx`).
2097            self.unsubscribe_notifications(sub_id).await;
2098            while let Ok(frame) = rx.try_recv() {
2099                let _ = Self::forward_invoke_command_frame(
2100                    &extension_id,
2101                    &request_id_owned,
2102                    &sink,
2103                    &mut sink_open,
2104                    frame,
2105                )
2106                .await;
2107            }
2108            response
2109        };
2110
2111        let outcome =
2112            tokio::time::timeout(std::time::Duration::from_secs(120), invoke_future).await;
2113
2114        // Belt-and-braces: ensure our subscription is cleared on timeout too.
2115        // Idempotent if the inner future already unsubscribed. (On timeout
2116        // the dropped `invoke_future` also drops `sink` and `rx`, releasing
2117        // the collector and any dispatcher blocked on this subscription.)
2118        self.unsubscribe_notifications(sub_id).await;
2119
2120        outcome.map_err(|_| format!("Extension '{}' command.invoke timed out", self.id))?
2121    }
2122
2123    async fn handle(&self, event: &HookEvent) -> HookResult {
2124        let params = serde_json::to_value(event).unwrap_or(Value::Null);
2125        match tokio::time::timeout(
2126            std::time::Duration::from_secs(5),
2127            self.call("hook.handle", params),
2128        )
2129        .await
2130        {
2131            Ok(Ok(value)) => match serde_json::from_value(value.clone()) {
2132                Ok(result) => result,
2133                Err(error) => {
2134                    tracing::warn!(
2135                        extension = %self.id,
2136                        error = %error,
2137                        response = %value,
2138                        "Extension hook handler returned invalid result",
2139                    );
2140                    match value.get("action").and_then(Value::as_str) {
2141                        // A malformed `modify` is dangerous — it would let an
2142                        // unknown/partial input through. Fail safe by blocking.
2143                        Some("modify") => HookResult::Block {
2144                            reason: "Extension returned malformed modify result".to_string(),
2145                        },
2146                        // A malformed `replace` is safe to ignore: the original
2147                        // tool output is preserved by emit_after_tool_call.
2148                        // Explicit arm so this intent can't be silently changed
2149                        // by a future edit to the catch-all below.
2150                        Some("replace") => HookResult::Continue,
2151                        _ => HookResult::Continue,
2152                    }
2153                }
2154            },
2155            Ok(Err(e)) => {
2156                tracing::warn!(
2157                    extension = %self.id,
2158                    error = %e,
2159                    "Extension hook handler failed — continuing",
2160                );
2161                HookResult::Continue
2162            }
2163            Err(_) => {
2164                tracing::warn!(
2165                    extension = %self.id,
2166                    timeout_secs = 5,
2167                    "Extension hook handler timed out — continuing",
2168                );
2169                HookResult::Continue
2170            }
2171        }
2172    }
2173
2174    async fn get_info(&self) -> Result<crate::extensions::info::PluginInfo, String> {
2175        let value = tokio::time::timeout(
2176            std::time::Duration::from_secs(5),
2177            self.call("info.get", Value::Null),
2178        )
2179        .await
2180        .map_err(|_| format!("Extension '{}' info.get timed out", self.id))??;
2181        serde_json::from_value(value).map_err(|e| {
2182            format!(
2183                "Invalid info.get response from extension '{}': {}",
2184                self.id, e
2185            )
2186        })
2187    }
2188
2189    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
2190        let value = tokio::time::timeout(
2191            std::time::Duration::from_secs(5),
2192            self.call("sidecar.spawn_args", Value::Null),
2193        )
2194        .await
2195        .map_err(|_| format!("Extension '{}' sidecar.spawn_args timed out", self.id))??;
2196        serde_json::from_value(value).map_err(|e| {
2197            format!(
2198                "Invalid sidecar.spawn_args response from extension '{}': {}",
2199                self.id, e
2200            )
2201        })
2202    }
2203
2204    async fn settings_editor_open(&self, category: &str, field: &str) -> Result<Value, String> {
2205        let params = crate::extensions::settings_editor::SettingsEditorOpenParams {
2206            category: category.to_string(),
2207            field: field.to_string(),
2208        };
2209        tokio::time::timeout(
2210            std::time::Duration::from_secs(5),
2211            self.call(
2212                "settings.editor.open",
2213                serde_json::to_value(params).map_err(|e| e.to_string())?,
2214            ),
2215        )
2216        .await
2217        .map_err(|_| format!("Extension '{}' settings.editor.open timed out", self.id))?
2218    }
2219
2220    async fn settings_editor_key(
2221        &self,
2222        category: &str,
2223        field: &str,
2224        key: &str,
2225    ) -> Result<Value, String> {
2226        let mut params = serde_json::to_value(
2227            crate::extensions::settings_editor::SettingsEditorKeyParams {
2228                key: key.to_string(),
2229            },
2230        )
2231        .map_err(|e| e.to_string())?;
2232        if let Some(obj) = params.as_object_mut() {
2233            obj.insert("category".to_string(), Value::String(category.to_string()));
2234            obj.insert("field".to_string(), Value::String(field.to_string()));
2235        }
2236        tokio::time::timeout(
2237            std::time::Duration::from_secs(5),
2238            self.call("settings.editor.key", params),
2239        )
2240        .await
2241        .map_err(|_| format!("Extension '{}' settings.editor.key timed out", self.id))?
2242    }
2243
2244    async fn settings_editor_commit(
2245        &self,
2246        category: &str,
2247        field: &str,
2248        value: Value,
2249    ) -> Result<Value, String> {
2250        let params = serde_json::json!({
2251            "category": category,
2252            "field": field,
2253            "value": value,
2254        });
2255        tokio::time::timeout(
2256            std::time::Duration::from_secs(5),
2257            self.call("settings.editor.commit", params),
2258        )
2259        .await
2260        .map_err(|_| format!("Extension '{}' settings.editor.commit timed out", self.id))?
2261    }
2262
2263    async fn shutdown(&self) {
2264        // Lease teardown is terminal for this ProcessExtension. In
2265        // particular, a concurrent force_shutdown may already have removed
2266        // the child; sending shutdown through the normal restart-capable
2267        // call path would then spawn an unvalidated replacement solely to
2268        // shut it down.
2269        let _ = tokio::time::timeout(
2270            std::time::Duration::from_millis(500),
2271            self.call_no_spawn("shutdown", Value::Null),
2272        )
2273        .await;
2274
2275        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2276        let mut state_guard = self.state.lock().await;
2277        if let Some(state) = state_guard.take() {
2278            state.reader_handle.abort();
2279            let mut child = state.child;
2280            let _ = child.kill().await;
2281        }
2282        // Drop all active notification subscribers and signal pending callers.
2283        self.inbox.notification_sinks.lock().await.clear();
2284        self.inbox
2285            .fail_all_pending("transport closed: extension shutdown")
2286            .await;
2287    }
2288
2289    async fn subscribe_notifications(
2290        &self,
2291    ) -> (usize, tokio::sync::mpsc::Receiver<NotificationFrame>) {
2292        ProcessExtension::subscribe_notifications(self).await
2293    }
2294
2295    async fn restart_count(&self) -> usize {
2296        self.restart_count()
2297    }
2298
2299    async fn health(&self) -> ExtensionHealth {
2300        // Exhaustion is judged on the consecutive-failure counter (resets on
2301        // successful restart); Degraded is judged on lifetime restarts so a
2302        // recovered-but-previously-crashed extension doesn't report Running.
2303        let consecutive = self.restart_count.load(Ordering::Relaxed);
2304        let lifetime = self.total_restarts.load(Ordering::Relaxed);
2305        let max = self.restart_policy.max_attempts as usize;
2306        if consecutive >= max {
2307            ExtensionHealth::Failed
2308        } else if lifetime > 0 {
2309            // Within budget. If the state slot is currently empty, we're
2310            // mid-restart; otherwise the process is alive but has previously
2311            // crashed at least once.
2312            let state_alive = self.state.try_lock().map(|g| g.is_some()).unwrap_or(true);
2313            if state_alive {
2314                ExtensionHealth::Degraded
2315            } else {
2316                ExtensionHealth::Restarting
2317            }
2318        } else {
2319            ExtensionHealth::Running
2320        }
2321    }
2322}
2323
2324#[cfg(test)]
2325mod stream_event_tests {
2326    use super::*;
2327    use serde_json::json;
2328
2329    #[test]
2330    fn parses_text_delta_with_delta_key() {
2331        let v = json!({"type": "text", "delta": "hi"});
2332        assert_eq!(
2333            parse_provider_stream_event(&v).unwrap(),
2334            ProviderStreamEvent::TextDelta { text: "hi".into() }
2335        );
2336    }
2337
2338    #[test]
2339    fn parses_text_delta_with_text_key() {
2340        let v = json!({"type": "text", "text": "hi"});
2341        assert_eq!(
2342            parse_provider_stream_event(&v).unwrap(),
2343            ProviderStreamEvent::TextDelta { text: "hi".into() }
2344        );
2345    }
2346
2347    #[test]
2348    fn parses_thinking_delta() {
2349        let v = json!({"type": "thinking", "delta": "hmm"});
2350        assert_eq!(
2351            parse_provider_stream_event(&v).unwrap(),
2352            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2353        );
2354        let v2 = json!({"type": "thinking", "text": "hmm"});
2355        assert_eq!(
2356            parse_provider_stream_event(&v2).unwrap(),
2357            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2358        );
2359    }
2360
2361    #[test]
2362    fn parses_tool_use() {
2363        let v = json!({
2364            "type": "tool_use",
2365            "id": "t1",
2366            "name": "echo",
2367            "input": {"x": 1}
2368        });
2369        assert_eq!(
2370            parse_provider_stream_event(&v).unwrap(),
2371            ProviderStreamEvent::ToolUse {
2372                id: "t1".into(),
2373                name: "echo".into(),
2374                input: json!({"x": 1}),
2375            }
2376        );
2377    }
2378
2379    #[test]
2380    fn tool_use_input_defaults_to_empty_object() {
2381        let v = json!({"type": "tool_use", "id": "t1", "name": "echo"});
2382        assert_eq!(
2383            parse_provider_stream_event(&v).unwrap(),
2384            ProviderStreamEvent::ToolUse {
2385                id: "t1".into(),
2386                name: "echo".into(),
2387                input: json!({}),
2388            }
2389        );
2390    }
2391
2392    #[test]
2393    fn parses_usage_strips_type() {
2394        let v = json!({"type": "usage", "input_tokens": 5, "output_tokens": 7});
2395        assert_eq!(
2396            parse_provider_stream_event(&v).unwrap(),
2397            ProviderStreamEvent::Usage {
2398                usage: json!({"input_tokens": 5, "output_tokens": 7})
2399            }
2400        );
2401    }
2402
2403    #[test]
2404    fn parses_error() {
2405        let v = json!({"type": "error", "message": "boom"});
2406        assert_eq!(
2407            parse_provider_stream_event(&v).unwrap(),
2408            ProviderStreamEvent::Error {
2409                message: "boom".into()
2410            }
2411        );
2412    }
2413
2414    #[test]
2415    fn parses_done() {
2416        let v = json!({"type": "done"});
2417        assert_eq!(
2418            parse_provider_stream_event(&v).unwrap(),
2419            ProviderStreamEvent::Done
2420        );
2421    }
2422
2423    #[test]
2424    fn nested_event_shape_matches_flat() {
2425        let flat = json!({"type": "text", "delta": "hi"});
2426        let nested = json!({"event": {"type": "text", "delta": "hi"}});
2427        assert_eq!(
2428            parse_provider_stream_event(&flat).unwrap(),
2429            parse_provider_stream_event(&nested).unwrap()
2430        );
2431    }
2432
2433    #[test]
2434    fn missing_type_errors() {
2435        let v = json!({"delta": "hi"});
2436        let err = parse_provider_stream_event(&v).unwrap_err();
2437        assert!(err.contains("missing type"), "got: {err}");
2438    }
2439
2440    #[test]
2441    fn unknown_type_errors_with_type() {
2442        let v = json!({"type": "wat"});
2443        let err = parse_provider_stream_event(&v).unwrap_err();
2444        assert!(err.contains("wat"), "got: {err}");
2445    }
2446
2447    #[test]
2448    fn tool_use_missing_id_errors() {
2449        let v = json!({"type": "tool_use", "name": "echo"});
2450        let err = parse_provider_stream_event(&v).unwrap_err();
2451        assert!(err.contains("id"), "got: {err}");
2452    }
2453
2454    #[test]
2455    fn tool_use_missing_name_errors() {
2456        let v = json!({"type": "tool_use", "id": "t1"});
2457        let err = parse_provider_stream_event(&v).unwrap_err();
2458        assert!(err.contains("name"), "got: {err}");
2459    }
2460
2461    #[test]
2462    fn tool_use_empty_id_errors() {
2463        let v = json!({"type": "tool_use", "id": "", "name": "echo"});
2464        assert!(parse_provider_stream_event(&v).is_err());
2465    }
2466
2467    #[test]
2468    fn tool_use_empty_name_errors() {
2469        let v = json!({"type": "tool_use", "id": "t1", "name": ""});
2470        assert!(parse_provider_stream_event(&v).is_err());
2471    }
2472
2473    #[test]
2474    fn tool_use_non_object_input_errors() {
2475        let v = json!({"type": "tool_use", "id": "t1", "name": "echo", "input": "nope"});
2476        let err = parse_provider_stream_event(&v).unwrap_err();
2477        assert!(err.contains("input"), "got: {err}");
2478    }
2479
2480    #[test]
2481    fn text_missing_delta_and_text_errors() {
2482        let v = json!({"type": "text"});
2483        let err = parse_provider_stream_event(&v).unwrap_err();
2484        assert!(err.contains("delta") || err.contains("text"), "got: {err}");
2485    }
2486
2487    #[test]
2488    fn error_missing_message_errors() {
2489        let v = json!({"type": "error"});
2490        assert!(parse_provider_stream_event(&v).is_err());
2491    }
2492
2493    #[test]
2494    fn error_empty_message_errors() {
2495        let v = json!({"type": "error", "message": ""});
2496        assert!(parse_provider_stream_event(&v).is_err());
2497    }
2498}
2499
2500#[cfg(test)]
2501mod restart_policy_tests {
2502    use super::*;
2503
2504    #[tokio::test]
2505    async fn restart_policy_default_max_attempts_is_3() {
2506        // Use a command that won't actually run; we only need the struct.
2507        // Since `spawn` actually launches the process, use a trivial echoer
2508        // and immediately shut it down. Falls back to /bin/cat which reads
2509        // from stdin and stays alive.
2510        let ext = ProcessExtension::spawn("policy-test", "/bin/cat", &[])
2511            .await
2512            .expect("spawn /bin/cat");
2513        assert_eq!(ext.restart_policy.max_attempts, 3);
2514        ext.shutdown().await;
2515    }
2516
2517    #[tokio::test]
2518    async fn with_restart_policy_overrides_default() {
2519        let ext = ProcessExtension::spawn("policy-test-override", "/bin/cat", &[])
2520            .await
2521            .expect("spawn /bin/cat");
2522        let custom = RestartPolicy {
2523            max_attempts: 7,
2524            ..RestartPolicy::default()
2525        };
2526        let ext = ext.with_restart_policy(custom);
2527        assert_eq!(ext.restart_policy.max_attempts, 7);
2528        ext.shutdown().await;
2529    }
2530}
2531
2532#[cfg(test)]
2533mod capture_validator_tests {
2534    use super::*;
2535    use crate::extensions::permissions::{Permission, PermissionSet};
2536
2537    fn perms_with(grants: &[Permission]) -> PermissionSet {
2538        let mut p = PermissionSet::new();
2539        for g in grants {
2540            p.grant(*g);
2541        }
2542        p
2543    }
2544
2545    fn cap(kind: &str, name: &str, perms: &[&str]) -> CapabilityDeclaration {
2546        CapabilityDeclaration {
2547            kind: kind.to_string(),
2548            name: name.to_string(),
2549            permissions: perms.iter().map(|p| p.to_string()).collect(),
2550            params: serde_json::Value::Null,
2551        }
2552    }
2553
2554    #[test]
2555    fn capability_validator_rejects_empty_kind() {
2556        let d = cap("   ", "Sample", &["audio.input"]);
2557        let perms = perms_with(&[Permission::AudioInput]);
2558        let err = validate_capability(&d, &perms).unwrap_err();
2559        assert!(err.contains("kind"), "got: {}", err);
2560    }
2561
2562    #[test]
2563    fn capability_validator_rejects_empty_name() {
2564        let d = cap("capture", "   ", &["audio.input"]);
2565        let perms = perms_with(&[Permission::AudioInput]);
2566        let err = validate_capability(&d, &perms).unwrap_err();
2567        assert!(err.contains("name"), "got: {}", err);
2568    }
2569
2570    #[test]
2571    fn capability_validator_rejects_unknown_permission_string() {
2572        let d = cap("capture", "Sample", &["audio.telepathy"]);
2573        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2574        let err = validate_capability(&d, &perms).unwrap_err();
2575        assert!(
2576            err.contains("unknown permission") && err.contains("audio.telepathy"),
2577            "got: {}",
2578            err,
2579        );
2580    }
2581
2582    #[test]
2583    fn capability_validator_requires_every_declared_permission() {
2584        let d = cap("capture", "Sample", &["audio.input"]);
2585        let perms = perms_with(&[]);
2586        let err = validate_capability(&d, &perms).unwrap_err();
2587        assert!(
2588            err.contains("audio.input") && err.contains("not granted"),
2589            "got: {}",
2590            err,
2591        );
2592    }
2593
2594    #[test]
2595    fn capability_validator_accepts_when_all_permissions_granted() {
2596        let d = cap("capture", "Sample", &["audio.input", "audio.output"]);
2597        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2598        validate_capability(&d, &perms).expect("should validate");
2599    }
2600
2601    #[test]
2602    fn capability_validator_accepts_no_permissions() {
2603        // Capabilities that genuinely don't need any permission (e.g. a
2604        // pure-CPU OCR sidecar that takes images via stdin) should
2605        // validate cleanly with an empty permission list.
2606        let d = cap("ocr", "Tesseract", &[]);
2607        let perms = perms_with(&[]);
2608        validate_capability(&d, &perms).expect("should validate");
2609    }
2610
2611    #[test]
2612    fn capability_validator_does_not_branch_on_kind() {
2613        // Two capabilities with identical permission requirements but
2614        // different `kind` values both validate identically — proves
2615        // the validator does not enumerate kinds.
2616        let perms = perms_with(&[Permission::AudioInput]);
2617        for kind in ["capture", "ocr", "agent", "foot_pedal", "eeg"] {
2618            let d = cap(kind, "Anything", &["audio.input"]);
2619            validate_capability(&d, &perms).expect("should validate");
2620        }
2621    }
2622}
2623
2624#[cfg(test)]
2625mod invoke_command_dispatch_tests {
2626    //! Phase B Phase 2/3 — exercise the notification dispatcher used by
2627    //! `command.invoke`. Spawning a real subprocess is not required: we feed
2628    //! `NotificationFrame`s through the production async forwarder into the
2629    //! bounded CP-11 fix-3 sink and assert the collected ordering.
2630    use super::*;
2631    use crate::extensions::commands::CommandOutputEvent;
2632    use crate::extensions::invoke_output::{invoke_event_channel, InvokeOutputBudget};
2633    use crate::extensions::runtime::InvokeCommandEvent;
2634    use crate::extensions::tasks::{TaskEvent, TaskKind};
2635    use serde_json::json;
2636
2637    fn frame(method: &str, params: serde_json::Value) -> NotificationFrame {
2638        NotificationFrame {
2639            method: method.to_string(),
2640            params,
2641        }
2642    }
2643
2644    /// Feed `frames` through the production forwarder into the bounded
2645    /// sink (the small frame counts here never exceed channel capacity,
2646    /// so sequential awaited sends cannot park), then collect. Returns
2647    /// the retained events and whether a Done marker was observed.
2648    async fn dispatch(frames: Vec<NotificationFrame>) -> (Vec<InvokeCommandEvent>, bool) {
2649        let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
2650        let mut open = true;
2651        let mut saw_done = false;
2652        for f in frames {
2653            saw_done |=
2654                ProcessExtension::forward_invoke_command_frame("ext-test", "r1", &tx, &mut open, f)
2655                    .await;
2656        }
2657        drop(tx);
2658        let report = collector.collect().await;
2659        (report.events, saw_done)
2660    }
2661
2662    #[tokio::test]
2663    async fn forwards_mixed_event_stream_in_order() {
2664        let frames = vec![
2665            frame(
2666                "command.output",
2667                json!({"request_id":"r1","event":{"kind":"text","content":"A"}}),
2668            ),
2669            frame(
2670                "task.start",
2671                json!({"id":"dl","label":"Downloading","kind":"download"}),
2672            ),
2673            frame("task.update", json!({"id":"dl","current":50,"total":100})),
2674            frame(
2675                "command.output",
2676                json!({"request_id":"r1","event":{"kind":"system","content":"working"}}),
2677            ),
2678            frame("task.done", json!({"id":"dl"})),
2679            frame(
2680                "command.output",
2681                json!({"request_id":"r1","event":{"kind":"done"}}),
2682            ),
2683        ];
2684
2685        let (events, saw_done) = dispatch(frames).await;
2686        assert!(saw_done, "should have observed the command Done marker");
2687        assert_eq!(events.len(), 6);
2688        assert_eq!(
2689            events[0],
2690            InvokeCommandEvent::Output(CommandOutputEvent::Text {
2691                content: "A".into()
2692            })
2693        );
2694        assert!(matches!(
2695            events[1],
2696            InvokeCommandEvent::Task(TaskEvent::Start {
2697                kind: TaskKind::Download,
2698                ..
2699            })
2700        ));
2701        assert!(matches!(
2702            events[2],
2703            InvokeCommandEvent::Task(TaskEvent::Update { .. })
2704        ));
2705        assert!(matches!(
2706            events[3],
2707            InvokeCommandEvent::Output(CommandOutputEvent::System { .. })
2708        ));
2709        assert!(matches!(
2710            events[4],
2711            InvokeCommandEvent::Task(TaskEvent::Done { error: None, .. })
2712        ));
2713        assert_eq!(
2714            events[5],
2715            InvokeCommandEvent::Output(CommandOutputEvent::Done)
2716        );
2717    }
2718
2719    #[tokio::test]
2720    async fn ignores_command_output_for_unrelated_request_id() {
2721        let (events, saw_done) = dispatch(vec![frame(
2722            "command.output",
2723            json!({"request_id":"other","event":{"kind":"text","content":"x"}}),
2724        )])
2725        .await;
2726        assert!(!saw_done);
2727        assert!(events.is_empty());
2728    }
2729
2730    #[tokio::test]
2731    async fn skips_malformed_command_output_without_aborting() {
2732        let (events, saw_done) = dispatch(vec![
2733            // Missing 'kind'
2734            frame("command.output", json!({"request_id":"r1","event":{}})),
2735            // Followed by a good event — must still be delivered.
2736            frame(
2737                "command.output",
2738                json!({"request_id":"r1","event":{"kind":"done"}}),
2739            ),
2740        ])
2741        .await;
2742        assert!(saw_done);
2743        assert_eq!(
2744            events,
2745            vec![InvokeCommandEvent::Output(CommandOutputEvent::Done)]
2746        );
2747    }
2748
2749    #[tokio::test]
2750    async fn task_events_pass_through_regardless_of_request_id() {
2751        let (events, _) = dispatch(vec![frame("task.log", json!({"id":"abc","line":"..."}))]).await;
2752        match &events[..] {
2753            [InvokeCommandEvent::Task(TaskEvent::Log { id, line })] => {
2754                assert_eq!(id, "abc");
2755                assert_eq!(line, "...");
2756            }
2757            other => panic!("unexpected: {other:?}"),
2758        }
2759    }
2760
2761    #[tokio::test]
2762    async fn unrelated_methods_are_dropped() {
2763        let (events, saw_done) = dispatch(vec![frame(
2764            "provider.stream.event",
2765            json!({"type":"text","delta":"x"}),
2766        )])
2767        .await;
2768        assert!(!saw_done);
2769        assert!(events.is_empty());
2770    }
2771
2772    /// A closed collector (dropped receiver) latches `sink_open` false and
2773    /// never blocks or aborts the dispatch loop.
2774    #[tokio::test]
2775    async fn closed_sink_latches_open_false_without_blocking() {
2776        let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
2777        drop(collector);
2778        let mut open = true;
2779        let saw_done = ProcessExtension::forward_invoke_command_frame(
2780            "ext-test",
2781            "r1",
2782            &tx,
2783            &mut open,
2784            frame(
2785                "command.output",
2786                json!({"request_id":"r1","event":{"kind":"done"}}),
2787            ),
2788        )
2789        .await;
2790        // Done detection is independent of sink state.
2791        assert!(saw_done);
2792        assert!(!open);
2793    }
2794}