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            // JSON-RPC 2.0 §4: `id` MUST be a String, Number, or Null. The
941            // extension picks its own request ids and is the only party that
942            // correlates them, so echo whatever it sent back verbatim rather
943            // than forcing it through u64.
944            //
945            // This previously required a numeric id and dropped everything
946            // else at trace level. `docs/extensions/protocol.md` shows string
947            // ids in its own examples, so an extension written against the
948            // documentation hung forever with no diagnostic. (#291 defect 3)
949            let id = match id_field {
950                Some(id @ (Value::String(_) | Value::Number(_))) => id.clone(),
951                _ => {
952                    tracing::warn!(
953                        extension = %extension_id,
954                        frame = %value,
955                        "Discarding inbound request: JSON-RPC id must be a string or a number",
956                    );
957                    return;
958                }
959            };
960            let Some(method) = method_field else { return };
961            let params = value.get("params").cloned().unwrap_or(Value::Null);
962            let inbox = inbox.clone();
963            let extension_id = extension_id.to_string();
964            tokio::spawn(async move {
965                let outcome = Self::handle_inbound_request(&inbox, &method, params).await;
966                let payload = match outcome {
967                    Ok(result) => serde_json::json!({
968                        "jsonrpc": "2.0",
969                        "id": id,
970                        "result": result,
971                    }),
972                    Err((code, message)) => serde_json::json!({
973                        "jsonrpc": "2.0",
974                        "id": id,
975                        "error": {"code": code, "message": message},
976                    }),
977                };
978                let stdin_handle = inbox.inbound_stdin.lock().await.clone();
979                if let Some(stdin) = stdin_handle {
980                    let body = match serde_json::to_string(&payload) {
981                        Ok(s) => s,
982                        Err(error) => {
983                            tracing::warn!(
984                                extension = %extension_id,
985                                error = %error,
986                                "Failed to serialize inbound response",
987                            );
988                            return;
989                        }
990                    };
991                    let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
992                    let mut stdin = stdin.lock().await;
993                    if let Err(error) = stdin.write_all(frame.as_bytes()).await {
994                        tracing::warn!(
995                            extension = %extension_id,
996                            error = %error,
997                            "Failed to write inbound response",
998                        );
999                        return;
1000                    }
1001                    if let Err(error) = stdin.flush().await {
1002                        tracing::warn!(
1003                            extension = %extension_id,
1004                            error = %error,
1005                            "Failed to flush inbound response",
1006                        );
1007                    }
1008                } else {
1009                    tracing::warn!(
1010                        extension = %extension_id,
1011                        "No stdin available to reply to inbound request",
1012                    );
1013                }
1014            });
1015            return;
1016        }
1017
1018        if id_is_present {
1019            // Response frames are different: the id must be one WE issued, and
1020            // the runtime only ever issues numeric ids. A non-numeric id here
1021            // cannot correlate to anything, so it is genuinely a protocol
1022            // error on the extension's side — say so at warn, not trace.
1023            let id = match id_field.and_then(Value::as_u64) {
1024                Some(id) => id,
1025                None => {
1026                    tracing::warn!(
1027                        extension = %extension_id,
1028                        frame = %value,
1029                        "Discarding response frame: id does not match any request \
1030                         the runtime issued (runtime request ids are always numeric)",
1031                    );
1032                    return;
1033                }
1034            };
1035            let sender = inbox.pending.lock().await.remove(&id);
1036            match sender {
1037                Some(tx) => {
1038                    let payload = if let Some(err) = value.get("error") {
1039                        let message = err
1040                            .get("message")
1041                            .and_then(Value::as_str)
1042                            .unwrap_or("unknown extension error")
1043                            .to_string();
1044                        Err(format!("Extension error: {}", message))
1045                    } else {
1046                        Ok(value.get("result").cloned().unwrap_or(Value::Null))
1047                    };
1048                    let _ = tx.send(payload);
1049                }
1050                None => {
1051                    tracing::trace!(
1052                        extension = %extension_id,
1053                        id = id,
1054                        "Response with unknown id (no pending request); dropping",
1055                    );
1056                }
1057            }
1058        } else if let Some(method) = value.get("method").and_then(Value::as_str) {
1059            let params = value.get("params").cloned().unwrap_or(Value::Null);
1060            let frame = NotificationFrame {
1061                method: method.to_string(),
1062                params,
1063            };
1064            // Snapshot the live subscribers WITHOUT holding the lock across
1065            // the awaited sends below — a subscriber unsubscribing while we
1066            // backpressure must not deadlock against this dispatch.
1067            let targets: Vec<(usize, mpsc::Sender<NotificationFrame>)> = {
1068                let sinks = inbox.notification_sinks.lock().await;
1069                sinks.to_vec()
1070            };
1071            if targets.is_empty() {
1072                tracing::trace!(
1073                    extension = %extension_id,
1074                    method = %method,
1075                    "Notification with no active subscribers; dropping",
1076                );
1077            } else {
1078                // Bounded fan-out: an un-draining subscriber BACKPRESSURES
1079                // this reader (and, through the stdout pipe, the child)
1080                // instead of growing host memory. `send` only errors when
1081                // the receiver is gone, so failure == dead subscriber.
1082                let mut dead = Vec::new();
1083                for (id, tx) in &targets {
1084                    if tx.send(frame.clone()).await.is_err() {
1085                        dead.push(*id);
1086                    }
1087                }
1088                if !dead.is_empty() {
1089                    let mut sinks = inbox.notification_sinks.lock().await;
1090                    sinks.retain(|(id, _)| !dead.contains(id));
1091                }
1092            }
1093        } else {
1094            tracing::trace!(
1095                extension = %extension_id,
1096                frame = %value,
1097                "Unrecognized frame; dropping",
1098            );
1099        }
1100    }
1101
1102    pub fn restart_count(&self) -> usize {
1103        // Lifetime total — reporting/tests expect total restarts, not the
1104        // consecutive-failure budget counter (which resets on success).
1105        self.total_restarts.load(Ordering::Relaxed)
1106    }
1107
1108    /// Public for tests: set the permission set used by inbound RPC handlers
1109    /// (e.g. memory.append). Called by the manager after manifest validation.
1110    pub async fn set_permissions(&self, perms: crate::extensions::permissions::PermissionSet) {
1111        *self.inbox.permissions.write().await = Some(perms);
1112    }
1113
1114    /// Handle a JSON-RPC request initiated by the extension.
1115    ///
1116    /// Returns `Ok(result_value)` on success or `Err((code, message))` for a
1117    /// JSON-RPC error response. Currently routes:
1118    /// - `memory.append` (requires `memory.write`)
1119    /// - `memory.query`  (requires `memory.read`)
1120    ///   All other methods return -32601 (method not found).
1121    #[allow(clippy::doc_lazy_continuation)]
1122    async fn handle_inbound_request(
1123        inbox: &Arc<Inbox>,
1124        method: &str,
1125        params: Value,
1126    ) -> Result<Value, (i32, String)> {
1127        use crate::extensions::permissions::Permission;
1128        use crate::memory::store::{self, MemoryQuery};
1129
1130        match method {
1131            "memory.append" => {
1132                Self::require_permission(inbox, Permission::MemoryWrite, "memory.write").await?;
1133                let namespace = Self::param_str(&params, "namespace")?;
1134                Self::require_namespace_matches(inbox, &namespace).await?;
1135                let content = Self::param_str(&params, "content")?;
1136                let tags = match params.get("tags") {
1137                    None | Some(Value::Null) => Vec::new(),
1138                    Some(Value::Array(arr)) => {
1139                        let mut out = Vec::with_capacity(arr.len());
1140                        for v in arr {
1141                            match v.as_str() {
1142                                Some(s) => out.push(s.to_string()),
1143                                None => {
1144                                    return Err((
1145                                        -32602,
1146                                        "tags must be an array of strings".to_string(),
1147                                    ))
1148                                }
1149                            }
1150                        }
1151                        out
1152                    }
1153                    _ => return Err((-32602, "tags must be an array of strings".to_string())),
1154                };
1155                let meta = match params.get("meta") {
1156                    None | Some(Value::Null) => None,
1157                    Some(v) => Some(v.clone()),
1158                };
1159                let record = store::new_record(namespace, content, tags, meta);
1160                let timestamp_ms = record.timestamp_ms;
1161                store::append(&record).map_err(|e| (-32000, e.to_string()))?;
1162                Ok(serde_json::json!({"ok": true, "timestamp_ms": timestamp_ms}))
1163            }
1164            "memory.query" => {
1165                Self::require_permission(inbox, Permission::MemoryRead, "memory.read").await?;
1166                let namespace = Self::param_str(&params, "namespace")?;
1167                Self::require_namespace_matches(inbox, &namespace).await?;
1168                let q = MemoryQuery {
1169                    content_contains: params
1170                        .get("content_contains")
1171                        .and_then(Value::as_str)
1172                        .map(str::to_string),
1173                    tag_prefix: params
1174                        .get("tag_prefix")
1175                        .and_then(Value::as_str)
1176                        .map(str::to_string),
1177                    since_ms: params.get("since_ms").and_then(Value::as_u64),
1178                    until_ms: params.get("until_ms").and_then(Value::as_u64),
1179                    limit: params
1180                        .get("limit")
1181                        .and_then(Value::as_u64)
1182                        .map(|n| n as usize),
1183                };
1184                let records = store::query(&namespace, &q).map_err(|e| (-32000, e.to_string()))?;
1185                Ok(serde_json::json!({"records": records}))
1186            }
1187            "config.get" => {
1188                let key = Self::param_str(&params, "key")?;
1189                Self::validate_config_key(&key)?;
1190                let value =
1191                    crate::extensions::config_store::read_plugin_config(&inbox.extension_id, &key);
1192                Ok(serde_json::json!({"value": value}))
1193            }
1194            "config.set" => {
1195                Self::require_permission(inbox, Permission::ConfigWrite, "config.write").await?;
1196                let key = Self::param_str(&params, "key")?;
1197                Self::validate_config_key(&key)?;
1198                let value = Self::param_str(&params, "value")?;
1199                crate::extensions::config_store::write_plugin_config(
1200                    &inbox.extension_id,
1201                    &key,
1202                    &value,
1203                )
1204                .map_err(|e| (-32000, e.to_string()))?;
1205                Ok(serde_json::json!({"ok": true}))
1206            }
1207            "config.subscribe" => {
1208                Self::require_permission(inbox, Permission::ConfigSubscribe, "config.subscribe")
1209                    .await?;
1210                // The long-lived watcher-to-notification bridge is wired at the manager/UI layer.
1211                // This phase exposes the authorized protocol ACK so plugins can opt in without
1212                // blocking initialize; direct store watchers are unit-tested in config_store.
1213                Ok(serde_json::json!({"ok": true}))
1214            }
1215            other => Err((-32601, format!("method not found: {other}"))),
1216        }
1217    }
1218
1219    async fn require_permission(
1220        inbox: &Arc<Inbox>,
1221        perm: crate::extensions::permissions::Permission,
1222        wire: &str,
1223    ) -> Result<(), (i32, String)> {
1224        let guard = inbox.permissions.read().await;
1225        match guard.as_ref() {
1226            Some(set) if set.has(perm) => Ok(()),
1227            _ => Err((-32602, format!("permission denied: {wire} required"))),
1228        }
1229    }
1230
1231    async fn require_namespace_matches(
1232        inbox: &Arc<Inbox>,
1233        namespace: &str,
1234    ) -> Result<(), (i32, String)> {
1235        if namespace == inbox.extension_id {
1236            Ok(())
1237        } else {
1238            Err((
1239                -32602,
1240                format!(
1241                    "namespace must equal extension id '{}' (got '{}')",
1242                    inbox.extension_id, namespace
1243                ),
1244            ))
1245        }
1246    }
1247
1248    fn param_str(params: &Value, name: &str) -> Result<String, (i32, String)> {
1249        params
1250            .get(name)
1251            .and_then(Value::as_str)
1252            .map(str::to_string)
1253            .ok_or_else(|| (-32602, format!("missing or invalid '{name}' parameter")))
1254    }
1255
1256    fn validate_config_key(key: &str) -> Result<(), (i32, String)> {
1257        let trimmed = key.trim();
1258        if trimmed.is_empty() {
1259            return Err((-32602, "config key must be non-empty".to_string()));
1260        }
1261        if trimmed.contains('.') || trimmed.contains('/') || trimmed.contains(' ') {
1262            return Err((
1263                -32602,
1264                "config key must not contain dots, slashes, or spaces".to_string(),
1265            ));
1266        }
1267        Ok(())
1268    }
1269
1270    pub async fn initialize(
1271        &self,
1272        plugin_root: Option<PathBuf>,
1273        config: Value,
1274    ) -> Result<InitializeCapabilitiesResult, String> {
1275        // Retain only in memory: restart handshakes must receive the same
1276        // resolved config, while secrets never enter env, logs, or storage.
1277        *self.initialize_config.lock().await = config.clone();
1278        let params = InitializeParams {
1279            synaps_version: env!("CARGO_PKG_VERSION"),
1280            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1281            plugin_id: self.id.clone(),
1282            plugin_root: plugin_root
1283                .or_else(|| self.cwd.clone())
1284                .map(|path| path.to_string_lossy().to_string()),
1285            config,
1286        };
1287        let value = self
1288            .call_no_restart(
1289                "initialize",
1290                serde_json::to_value(params).map_err(|e| e.to_string())?,
1291            )
1292            .await?;
1293        Self::parse_initialize_result(&self.id, value)
1294    }
1295
1296    fn parse_initialize_result(
1297        id: &str,
1298        value: Value,
1299    ) -> Result<InitializeCapabilitiesResult, String> {
1300        let result: InitializeResult = serde_json::from_value(value)
1301            .map_err(|e| format!("Invalid initialize response from extension '{}': {}", id, e))?;
1302        if result.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
1303            return Err(format!(
1304                "Extension '{}' initialize returned unsupported protocol_version {} (supported: {})",
1305                id, result.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
1306            ));
1307        }
1308        Self::validate_registered_tool_specs(id, &result.capabilities.tools)?;
1309        Self::validate_registered_provider_specs(id, &result.capabilities.providers)?;
1310        Ok(InitializeCapabilitiesResult {
1311            tools: result.capabilities.tools,
1312            providers: result.capabilities.providers,
1313            capabilities: result.capabilities.capabilities,
1314        })
1315    }
1316
1317    fn validate_registered_tool_specs(
1318        id: &str,
1319        tools: &[RegisteredExtensionToolSpec],
1320    ) -> Result<(), String> {
1321        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1322        let mut names = HashSet::new();
1323        for tool in tools {
1324            let name = tool.name.trim();
1325            if let Err(err) = validate_id_segment(name) {
1326                return Err(match err {
1327                    IdValidationError::Empty => format!(
1328                        "Extension '{}' registered a tool with an empty tool name",
1329                        id
1330                    ),
1331                    IdValidationError::ContainsReserved { ch } => format!(
1332                        "Extension '{}' registered tool '{}' with invalid tool name: '{}' is reserved",
1333                        id, name, ch
1334                    ),
1335                    IdValidationError::TooLong { len, max } => format!(
1336                        "Extension '{}' registered tool '{}' with invalid tool name: must be at most {} chars (got {})",
1337                        id, name, max, len
1338                    ),
1339                    IdValidationError::ContainsWhitespace => format!(
1340                        "Extension '{}' registered tool '{}' with invalid tool name: must not contain whitespace",
1341                        id, name
1342                    ),
1343                    IdValidationError::ContainsControl { ch } => format!(
1344                        "Extension '{}' registered tool '{}' with invalid tool name: contains control character U+{:04X}",
1345                        id, name, ch as u32
1346                    ),
1347                });
1348            }
1349            if !names.insert(name.to_string()) {
1350                return Err(format!(
1351                    "Extension '{}' registered duplicate tool name '{}'",
1352                    id, name
1353                ));
1354            }
1355            if tool.description.trim().is_empty() {
1356                return Err(format!(
1357                    "Extension '{}' registered tool '{}' with an empty description",
1358                    id, name,
1359                ));
1360            }
1361            if !tool.input_schema.is_object() {
1362                return Err(format!(
1363                    "Extension '{}' registered tool '{}' with invalid input_schema: input_schema must be a JSON object",
1364                    id, name,
1365                ));
1366            }
1367        }
1368        Ok(())
1369    }
1370
1371    fn validate_registered_provider_specs(
1372        id: &str,
1373        providers: &[RegisteredProviderSpec],
1374    ) -> Result<(), String> {
1375        use crate::extensions::validation::{validate_id_segment, IdValidationError};
1376        for provider in providers {
1377            let provider_id = provider.id.trim();
1378            match validate_id_segment(provider_id) {
1379                Ok(()) => {
1380                    if !Self::is_safe_provider_id(provider_id) {
1381                        return Err(format!(
1382                            "Extension '{}' registered provider '{}' with invalid provider id",
1383                            id, provider_id
1384                        ));
1385                    }
1386                }
1387                Err(IdValidationError::Empty) => {
1388                    return Err(format!(
1389                        "Extension '{}' registered provider with empty provider id",
1390                        id
1391                    ));
1392                }
1393                Err(err) => {
1394                    return Err(format!(
1395                        "Extension '{}' registered provider '{}' with invalid provider id: {}",
1396                        id, provider_id, err
1397                    ));
1398                }
1399            }
1400            if provider.display_name.trim().is_empty() {
1401                return Err(format!(
1402                    "Extension '{}' registered provider '{}' with empty display_name",
1403                    id, provider_id,
1404                ));
1405            }
1406            if provider.description.trim().is_empty() {
1407                return Err(format!(
1408                    "Extension '{}' registered provider '{}' with empty description",
1409                    id, provider_id,
1410                ));
1411            }
1412            if provider.models.is_empty() {
1413                return Err(format!(
1414                    "Extension '{}' registered provider '{}' must declare at least one model",
1415                    id, provider_id,
1416                ));
1417            }
1418            let mut model_ids = HashSet::new();
1419            for model in &provider.models {
1420                let model_id = model.id.trim();
1421                if let Err(err) = validate_id_segment(model_id) {
1422                    return Err(match err {
1423                        IdValidationError::Empty => format!(
1424                            "Extension '{}' registered provider '{}' with empty model id",
1425                            id, provider_id
1426                        ),
1427                        IdValidationError::ContainsReserved { ch } => format!(
1428                            "Extension '{}' registered provider '{}' with invalid model id '{}': '{}' is reserved",
1429                            id, provider_id, model_id, ch
1430                        ),
1431                        IdValidationError::TooLong { len, max } => format!(
1432                            "Extension '{}' registered provider '{}' with invalid model id '{}': must be at most {} chars (got {})",
1433                            id, provider_id, model_id, max, len
1434                        ),
1435                        IdValidationError::ContainsWhitespace => format!(
1436                            "Extension '{}' registered provider '{}' with invalid model id '{}': must not contain whitespace",
1437                            id, provider_id, model_id
1438                        ),
1439                        IdValidationError::ContainsControl { ch } => format!(
1440                            "Extension '{}' registered provider '{}' with invalid model id '{}': contains control character U+{:04X}",
1441                            id, provider_id, model_id, ch as u32
1442                        ),
1443                    });
1444                }
1445                if !model_ids.insert(model_id.to_string()) {
1446                    return Err(format!(
1447                        "Extension '{}' registered provider '{}' with duplicate model id '{}'",
1448                        id, provider_id, model_id,
1449                    ));
1450                }
1451            }
1452            if let Some(config_schema) = &provider.config_schema {
1453                if !config_schema.is_object() {
1454                    return Err(format!(
1455                        "Extension '{}' registered provider '{}' with invalid config_schema: config_schema must be a JSON object",
1456                        id, provider_id,
1457                    ));
1458                }
1459            }
1460        }
1461        Ok(())
1462    }
1463
1464    fn is_safe_provider_id(id: &str) -> bool {
1465        !id.is_empty()
1466            && !id.contains(':')
1467            && id
1468                .chars()
1469                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1470    }
1471
1472    #[doc(hidden)]
1473    pub async fn initialize_for_test(&self, plugin_root: Option<PathBuf>) -> Result<(), String> {
1474        self.initialize(plugin_root, Value::Object(Default::default()))
1475            .await
1476            .map(|_| ())
1477    }
1478
1479    async fn restart_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1480        let attempted = self.restart_count.fetch_add(1, Ordering::Relaxed) + 1;
1481        self.total_restarts.fetch_add(1, Ordering::Relaxed);
1482        let max_attempts = self.restart_policy.max_attempts;
1483        if attempted > max_attempts as usize {
1484            *state = None;
1485            return Err(format!(
1486                "Extension '{}' exceeded restart limit ({})",
1487                self.id, max_attempts,
1488            ));
1489        }
1490
1491        if let Some(old) = state.take() {
1492            old.reader_handle.abort();
1493            let mut child = old.child;
1494            let _ = child.kill().await;
1495        }
1496        // Drain any stale pending entries before reusing the inbox.
1497        self.inbox
1498            .fail_all_pending("transport closed: process restarting")
1499            .await;
1500
1501        let delay = self
1502            .restart_policy
1503            .delay_for_attempt(attempted as u32)
1504            .unwrap_or_default();
1505
1506        tracing::warn!(
1507            extension = %self.id,
1508            attempt = attempted,
1509            max_attempts = max_attempts,
1510            delay_ms = delay.as_millis() as u64,
1511            "Restarting extension process after transport failure",
1512        );
1513
1514        if !delay.is_zero() {
1515            tokio::time::sleep(delay).await;
1516        }
1517
1518        *state = Some(
1519            Self::spawn_state(
1520                &self.id,
1521                &self.command,
1522                &self.args,
1523                self.cwd.as_ref(),
1524                self.inbox.clone(),
1525            )
1526            .await?,
1527        );
1528        // Reset closed flag now that we have a fresh transport
1529        self.inbox
1530            .closed
1531            .store(false, std::sync::atomic::Ordering::Release);
1532        self.initialize_locked(state).await?;
1533        // NOTE: the consecutive-failure counter is NOT reset here. A
1534        // successful handshake isn't proof of recovery — a crash-looping
1535        // extension can initialize fine and die on the next request
1536        // (always_exit fixture). The reset happens in call_inner once the
1537        // restarted process actually serves a call (preserves H-8 intent:
1538        // transient failures hours apart don't accumulate toward disable).
1539        Ok(())
1540    }
1541
1542    async fn initialize_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1543        let config = self.initialize_config.lock().await.clone();
1544        let params = InitializeParams {
1545            synaps_version: env!("CARGO_PKG_VERSION"),
1546            extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1547            plugin_id: self.id.clone(),
1548            plugin_root: self
1549                .cwd
1550                .clone()
1551                .map(|path| path.to_string_lossy().to_string()),
1552            config,
1553        };
1554        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1555        let value = tokio::time::timeout(
1556            std::time::Duration::from_secs(10),
1557            self.call_once_locked(
1558                state.as_mut().expect("state should exist for initialize"),
1559                "initialize",
1560                serde_json::to_value(params).map_err(|e| e.to_string())?,
1561                id,
1562            ),
1563        )
1564        .await
1565        .map_err(|_| format!("Extension '{}' initialize timed out after 10s", self.id))??;
1566        Self::parse_initialize_result(&self.id, value).map(|_| ())
1567    }
1568
1569    /// Send a single JSON-RPC request and await the matching response,
1570    /// using the shared inbox for response delivery. The reader task runs
1571    /// concurrently and may dispatch interleaved notifications.
1572    async fn call_once_locked(
1573        &self,
1574        state: &mut ProcessState,
1575        method: &str,
1576        params: Value,
1577        id: u64,
1578    ) -> Result<Value, String> {
1579        let body = serde_json::to_string(&JsonRpcRequest {
1580            jsonrpc: "2.0",
1581            method: method.to_string(),
1582            params,
1583            id,
1584        })
1585        .map_err(|e| format!("Serialize error: {}", e))?;
1586
1587        let (tx, rx) = oneshot::channel::<Result<Value, String>>();
1588        // Check if inbox is closed before registering — if the reader already
1589        // exited, no one will ever send a response on this channel.
1590        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1591            return Err("transport closed: inbox is shut down".to_string());
1592        }
1593
1594        // Register pending BEFORE writing so the reader can route a fast
1595        // response without racing against the insert.
1596        self.inbox.pending.lock().await.insert(id, tx);
1597
1598        // Double-check: if closed was set between our check and the insert,
1599        // remove the entry and fail immediately.
1600        if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1601            self.inbox.pending.lock().await.remove(&id);
1602            return Err("transport closed: inbox shut down during registration".to_string());
1603        }
1604
1605        let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
1606        let write_result = {
1607            let mut stdin = state.stdin.lock().await;
1608            match stdin.write_all(frame.as_bytes()).await {
1609                Ok(()) => stdin.flush().await,
1610                Err(e) => Err(e),
1611            }
1612        };
1613        if let Err(e) = write_result {
1614            // Make sure we don't leak the pending entry if the write fails.
1615            self.inbox.pending.lock().await.remove(&id);
1616            return Err(format!("Write error: {}", e));
1617        }
1618
1619        match rx.await {
1620            Ok(payload) => payload,
1621            Err(_) => {
1622                // Sender was dropped without sending — typically because the
1623                // reader task observed EOF/error after we registered. The
1624                // reader normally sends an Err first; this branch is a
1625                // belt-and-braces fallback.
1626                self.inbox.pending.lock().await.remove(&id);
1627                Err("transport closed: response channel dropped".to_string())
1628            }
1629        }
1630    }
1631
1632    async fn call_no_restart(&self, method: &str, params: Value) -> Result<Value, String> {
1633        let _call_guard = self.call_lock.lock().await;
1634        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1635        let mut state_guard = self.state.lock().await;
1636        if state_guard.is_none() {
1637            *state_guard = Some(
1638                Self::spawn_state(
1639                    &self.id,
1640                    &self.command,
1641                    &self.args,
1642                    self.cwd.as_ref(),
1643                    self.inbox.clone(),
1644                )
1645                .await?,
1646            );
1647        }
1648        self.call_once_locked(
1649            state_guard.as_mut().expect("state should exist"),
1650            method,
1651            params,
1652            id,
1653        )
1654        .await
1655    }
1656
1657    /// Send one request only if the current child is still present. Teardown
1658    /// must never resurrect a child that concurrent cancellation already
1659    /// removed, so unlike [`Self::call`] this bypasses all spawn/restart paths.
1660    async fn call_no_spawn(&self, method: &str, params: Value) -> Result<Value, String> {
1661        let _call_guard = self.call_lock.lock().await;
1662        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1663        let mut state_guard = self.state.lock().await;
1664        let Some(state) = state_guard.as_mut() else {
1665            return Err(format!(
1666                "Extension '{}' process is not running; '{}' refused (no respawn)",
1667                self.id, method
1668            ));
1669        };
1670        self.call_once_locked(state, method, params, id).await
1671    }
1672
1673    /// Task 20 lease path: one `tool.call` that NEVER (re)spawns. A missing
1674    /// process state fails typed instead of resurrecting a terminated
1675    /// child, and the restart machinery is bypassed entirely — a lease
1676    /// child that dies is a transport error, never a silent new process
1677    /// whose declarations were not re-validated.
1678    pub(crate) async fn call_tool_once_no_spawn(
1679        &self,
1680        name: &str,
1681        input: Value,
1682    ) -> Result<Value, String> {
1683        let fut = async {
1684            let _call_guard = self.call_lock.lock().await;
1685            let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1686            // Take the running state out while awaiting the response so a
1687            // concurrent cancellation can observe an empty slot and still
1688            // fail the shared inbox pending map. The state is restored only
1689            // if cancellation did not close the inbox.
1690            let mut state = {
1691                let mut state_guard = self.state.lock().await;
1692                state_guard.take().ok_or_else(|| {
1693                    format!(
1694                        "Extension '{}' process is not running; leased call refused (no respawn)",
1695                        self.id
1696                    )
1697                })?
1698            };
1699            let result = self
1700                .call_once_locked(
1701                    &mut state,
1702                    "tool.call",
1703                    serde_json::json!({"name": name, "input": input}),
1704                    id,
1705                )
1706                .await;
1707            if !self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1708                *self.state.lock().await = Some(state);
1709            } else {
1710                state.reader_handle.abort();
1711                let _ = state.child.kill().await;
1712            }
1713            result
1714        };
1715        match tokio::time::timeout(std::time::Duration::from_secs(120), fut).await {
1716            Ok(result) => result,
1717            Err(_) => Err(format!(
1718                "Extension '{}' leased tool.call timed out after 120s",
1719                self.id
1720            )),
1721        }
1722    }
1723
1724    async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
1725        let timeout_secs = if method == "tool.call" { 120 } else { 30 };
1726        let id_str = self.id.clone();
1727        let method_str = method.to_string();
1728
1729        let result = tokio::time::timeout(
1730            std::time::Duration::from_secs(timeout_secs),
1731            self.call_inner(method, params),
1732        )
1733        .await;
1734
1735        match result {
1736            Ok(inner) => inner,
1737            Err(_) => Err(format!(
1738                "Extension '{}' method '{}' timed out after {}s",
1739                id_str, method_str, timeout_secs
1740            )),
1741        }
1742    }
1743
1744    async fn call_inner(&self, method: &str, params: Value) -> Result<Value, String> {
1745        let _call_guard = self.call_lock.lock().await;
1746        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1747        let mut state_guard = self.state.lock().await;
1748        if state_guard.is_none() {
1749            self.restart_locked(&mut state_guard).await?;
1750        }
1751
1752        let result = self
1753            .call_once_locked(
1754                state_guard
1755                    .as_mut()
1756                    .expect("state should exist after restart"),
1757                method,
1758                params.clone(),
1759                id,
1760            )
1761            .await;
1762
1763        match result {
1764            Ok(value) => {
1765                // Process served a real call — it has genuinely recovered.
1766                // Reset the consecutive-failure budget (H-8) so transient
1767                // crashes hours apart don't accumulate toward disable.
1768                self.restart_count.store(0, Ordering::Relaxed);
1769                Ok(value)
1770            }
1771            Err(first_error) => {
1772                self.restart_locked(&mut state_guard).await?;
1773                let retry_id = self.next_id.fetch_add(1, Ordering::Relaxed);
1774                self.call_once_locked(
1775                    state_guard
1776                        .as_mut()
1777                        .expect("state should exist after restart"),
1778                    method,
1779                    params,
1780                    retry_id,
1781                )
1782                .await
1783                .inspect(|_| {
1784                    // Retry succeeded post-restart — also a real recovery.
1785                    self.restart_count.store(0, Ordering::Relaxed);
1786                })
1787                .map_err(|retry_error| {
1788                    format!(
1789                        "{}; retry after restart failed: {}",
1790                        first_error, retry_error
1791                    )
1792                })
1793            }
1794        }
1795    }
1796
1797    /// Subscribe to JSON-RPC notifications emitted by the extension.
1798    ///
1799    /// Returns `(subscription_id, receiver)`. The id must be passed back to
1800    /// [`unsubscribe_notifications`] to cancel this subscription specifically
1801    /// — concurrent subscribers (e.g. background widget watchers running
1802    /// alongside `command.invoke` / `provider.stream`) coexist and each
1803    /// receives every notification frame.
1804    ///
1805    /// The receiver yields every notification frame (no `id`, has `method`)
1806    /// the extension sends until either:
1807    /// - the receiver is dropped (its slot is pruned on the next dispatch),
1808    /// - [`unsubscribe_notifications`] is called with this id, or
1809    /// - the reader observes EOF or a transport error (all subscribers are
1810    ///   dropped together).
1811    ///
1812    /// Internal API: exposed publicly with `#[doc(hidden)]` only so
1813    /// integration tests can exercise the bidirectional transport.
1814    #[doc(hidden)]
1815    pub async fn subscribe_notifications(&self) -> (usize, mpsc::Receiver<NotificationFrame>) {
1816        let (tx, rx) = mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
1817        let id = self
1818            .inbox
1819            .next_sink_id
1820            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1821        let mut sinks = self.inbox.notification_sinks.lock().await;
1822        // Opportunistically prune any subscribers whose receivers have been
1823        // dropped, so the Vec doesn't grow unboundedly across long-lived
1824        // extensions if callers forget to unsubscribe.
1825        sinks.retain(|(_, tx)| !tx.is_closed());
1826        sinks.push((id, tx));
1827        (id, rx)
1828    }
1829
1830    /// Drop the notification subscription with the given id, if any.
1831    /// Unknown ids are silently ignored (idempotent — safe to call on
1832    /// belt-and-braces timeout paths even after the inner future already
1833    /// unsubscribed).
1834    #[doc(hidden)]
1835    pub async fn unsubscribe_notifications(&self, id: usize) {
1836        let mut sinks = self.inbox.notification_sinks.lock().await;
1837        sinks.retain(|(sub_id, tx)| *sub_id != id && !tx.is_closed());
1838    }
1839
1840    /// Forward one notification frame received during `command.invoke`.
1841    ///
1842    /// Routes:
1843    /// - `command.output` whose `request_id` matches → sink as `Output(event)`
1844    ///   (other request_ids are ignored — concurrent invocations must use distinct ids
1845    ///   and may overlap in the same `subscribe_notifications` channel; mismatched
1846    ///   frames are dropped here intentionally).
1847    /// - `task.start|update|log|done` → sink as `Task(event)` regardless of request_id.
1848    /// - Anything else → logged at trace and dropped.
1849    ///
1850    /// CP-11 fix-3: the sink is BOUNDED
1851    /// ([`crate::extensions::invoke_output::InvokeEventSink`]) and its
1852    /// send is AWAITED — a hostile `command.output` flood backpressures
1853    /// this loop (and transitively the bounded notification queue and the
1854    /// sidecar's stdout pipe) instead of parking aggregate bytes in an
1855    /// unbounded queue. Liveness relies on the eagerly concurrent
1856    /// collector on the other end (see `invoke_output` module docs); if
1857    /// that consumer is gone, the send errs and `sink_open` latches false.
1858    pub(crate) async fn forward_invoke_command_frame(
1859        extension_id: &str,
1860        request_id: &str,
1861        sink: &crate::extensions::invoke_output::InvokeEventSink,
1862        sink_open: &mut bool,
1863        frame: NotificationFrame,
1864    ) -> bool {
1865        use crate::extensions::commands::parse_command_output;
1866        use crate::extensions::runtime::InvokeCommandEvent;
1867        use crate::extensions::tasks::{is_task_method, parse_task_event};
1868
1869        let mut saw_done = false;
1870        if frame.method == "command.output" {
1871            match parse_command_output(&frame.params) {
1872                Ok(parsed) if parsed.request_id == request_id => {
1873                    if matches!(
1874                        parsed.event,
1875                        crate::extensions::commands::CommandOutputEvent::Done
1876                    ) {
1877                        saw_done = true;
1878                    }
1879                    if *sink_open
1880                        && sink
1881                            .send(InvokeCommandEvent::Output(parsed.event))
1882                            .await
1883                            .is_err()
1884                    {
1885                        *sink_open = false;
1886                    }
1887                }
1888                Ok(_) => {
1889                    // Different request_id — ignore (logged at trace for diagnostics).
1890                    tracing::trace!(
1891                        extension = %extension_id,
1892                        "Ignoring command.output for unrelated request_id",
1893                    );
1894                }
1895                Err(error) => {
1896                    tracing::warn!(
1897                        extension = %extension_id,
1898                        error = %error,
1899                        params = %frame.params,
1900                        "Skipping malformed command.output notification",
1901                    );
1902                }
1903            }
1904        } else if is_task_method(&frame.method) {
1905            match parse_task_event(&frame.method, &frame.params) {
1906                Ok(event) => {
1907                    if *sink_open && sink.send(InvokeCommandEvent::Task(event)).await.is_err() {
1908                        *sink_open = false;
1909                    }
1910                }
1911                Err(error) => {
1912                    tracing::warn!(
1913                        extension = %extension_id,
1914                        method = %frame.method,
1915                        error = %error,
1916                        params = %frame.params,
1917                        "Skipping malformed task notification",
1918                    );
1919                }
1920            }
1921        } else {
1922            tracing::trace!(
1923                extension = %extension_id,
1924                method = %frame.method,
1925                "Ignoring non-command/task notification during command.invoke",
1926            );
1927        }
1928        saw_done
1929    }
1930
1931    /// Forward one notification frame received during `provider.stream`.
1932    ///
1933    /// - Frames whose method is not `provider.stream.event` are ignored (logged at trace).
1934    /// - Malformed event params are logged at warn and skipped (do not abort the call).
1935    /// - The sink is BOUNDED ([`PROVIDER_EVENT_QUEUE_CAPACITY`]): a slow
1936    ///   downstream backpressures this loop (and transitively the sidecar)
1937    ///   instead of retaining hostile flood events in host memory.
1938    /// - If the caller's sink has been closed, sets `sink_open = false` and stops forwarding,
1939    ///   but the in-flight request is still allowed to complete.
1940    async fn forward_provider_stream_frame(
1941        extension_id: &str,
1942        sink: &mpsc::Sender<ProviderStreamEvent>,
1943        sink_open: &mut bool,
1944        frame: NotificationFrame,
1945    ) {
1946        if frame.method != "provider.stream.event" {
1947            tracing::trace!(
1948                extension = %extension_id,
1949                method = %frame.method,
1950                "Ignoring non-stream notification during provider.stream",
1951            );
1952            return;
1953        }
1954        match parse_provider_stream_event(&frame.params) {
1955            Ok(event) => {
1956                if *sink_open && sink.send(event).await.is_err() {
1957                    *sink_open = false;
1958                }
1959            }
1960            Err(error) => {
1961                tracing::warn!(
1962                    extension = %extension_id,
1963                    error = %error,
1964                    params = %frame.params,
1965                    "Skipping malformed provider.stream.event notification",
1966                );
1967            }
1968        }
1969    }
1970}
1971
1972#[async_trait]
1973impl ExtensionHandler for ProcessExtension {
1974    fn id(&self) -> &str {
1975        &self.id
1976    }
1977
1978    async fn call_tool(&self, name: &str, input: Value) -> Result<Value, String> {
1979        self.call(
1980            "tool.call",
1981            serde_json::json!({
1982                "name": name,
1983                "input": input,
1984            }),
1985        )
1986        .await
1987    }
1988
1989    async fn provider_complete(
1990        &self,
1991        params: ProviderCompleteParams,
1992    ) -> Result<ProviderCompleteResult, String> {
1993        let value = tokio::time::timeout(
1994            std::time::Duration::from_secs(60),
1995            self.call(
1996                "provider.complete",
1997                serde_json::to_value(params).map_err(|e| e.to_string())?,
1998            ),
1999        )
2000        .await
2001        .map_err(|_| format!("Extension '{}' provider.complete timed out", self.id))??;
2002        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
2003            format!(
2004                "Invalid provider.complete response from extension '{}': {}",
2005                self.id, e
2006            )
2007        })?;
2008        if result.content.is_empty() {
2009            return Err(format!(
2010                "Extension '{}' provider.complete returned empty content",
2011                self.id
2012            ));
2013        }
2014        Ok(result)
2015    }
2016
2017    async fn provider_stream(
2018        &self,
2019        params: ProviderCompleteParams,
2020        sink: tokio::sync::mpsc::Sender<ProviderStreamEvent>,
2021    ) -> Result<ProviderCompleteResult, String> {
2022        // Subscribe BEFORE issuing the request so we don't miss early
2023        // notifications that may arrive before `call(...)` even starts polling.
2024        let (sub_id, mut rx) = self.subscribe_notifications().await;
2025        let params_value = serde_json::to_value(params).map_err(|e| e.to_string())?;
2026
2027        let extension_id = self.id.clone();
2028        let stream_future = async {
2029            let mut call_fut = Box::pin(self.call("provider.stream", params_value));
2030            let mut sink_open = true;
2031            let response = loop {
2032                tokio::select! {
2033                    response = &mut call_fut => break response,
2034                    Some(frame) = rx.recv() => {
2035                        Self::forward_provider_stream_frame(
2036                            &extension_id, &sink, &mut sink_open, frame,
2037                        ).await;
2038                    }
2039                }
2040            };
2041            // Response received: drop our subscription so this receiver yields
2042            // `None` once buffered frames are drained, then flush any
2043            // remaining notifications before returning. Other concurrent
2044            // subscribers (if any) are untouched.
2045            self.unsubscribe_notifications(sub_id).await;
2046            while let Some(frame) = rx.recv().await {
2047                Self::forward_provider_stream_frame(&extension_id, &sink, &mut sink_open, frame)
2048                    .await;
2049            }
2050            response
2051        };
2052
2053        let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), stream_future).await;
2054
2055        // Belt-and-braces: ensure our subscription is cleared on timeout too.
2056        // Idempotent if the inner future already unsubscribed.
2057        self.unsubscribe_notifications(sub_id).await;
2058
2059        let value =
2060            outcome.map_err(|_| format!("Extension '{}' provider.stream timed out", self.id))??;
2061
2062        let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
2063            format!(
2064                "Invalid provider.stream response from extension '{}': {}",
2065                self.id, e
2066            )
2067        })?;
2068        // NOTE: empty `content` is permitted for streaming — output may have
2069        // been delivered entirely via TextDelta notifications.
2070        Ok(result)
2071    }
2072
2073    async fn invoke_command(
2074        &self,
2075        command: &str,
2076        args: Vec<String>,
2077        request_id: &str,
2078        sink: crate::extensions::invoke_output::InvokeEventSink,
2079    ) -> Result<Value, String> {
2080        // Subscribe before issuing the request so we don't miss early events.
2081        let (sub_id, mut rx) = self.subscribe_notifications().await;
2082        let params = serde_json::json!({
2083            "command": command,
2084            "args": args,
2085            "request_id": request_id,
2086        });
2087
2088        let extension_id = self.id.clone();
2089        let request_id_owned = request_id.to_string();
2090        let invoke_future = async {
2091            let mut call_fut = Box::pin(self.call("command.invoke", params));
2092            let mut sink_open = true;
2093            let response = loop {
2094                tokio::select! {
2095                    response = &mut call_fut => break response,
2096                    Some(frame) = rx.recv() => {
2097                        // The awaited bounded send paces a flood; while it
2098                        // waits, `call_fut` is simply not polled — safe
2099                        // because the eagerly concurrent collector always
2100                        // drains (see invoke_output module docs), and if it
2101                        // is gone the send errs immediately.
2102                        let _ = Self::forward_invoke_command_frame(
2103                            &extension_id, &request_id_owned, &sink, &mut sink_open, frame,
2104                        ).await;
2105                    }
2106                }
2107            };
2108            // Drain any notifications already buffered after the response lands, but
2109            // do not wait for the subscriber channel to close (that would deadlock
2110            // while this invocation still owns `rx`).
2111            self.unsubscribe_notifications(sub_id).await;
2112            while let Ok(frame) = rx.try_recv() {
2113                let _ = Self::forward_invoke_command_frame(
2114                    &extension_id,
2115                    &request_id_owned,
2116                    &sink,
2117                    &mut sink_open,
2118                    frame,
2119                )
2120                .await;
2121            }
2122            response
2123        };
2124
2125        let outcome =
2126            tokio::time::timeout(std::time::Duration::from_secs(120), invoke_future).await;
2127
2128        // Belt-and-braces: ensure our subscription is cleared on timeout too.
2129        // Idempotent if the inner future already unsubscribed. (On timeout
2130        // the dropped `invoke_future` also drops `sink` and `rx`, releasing
2131        // the collector and any dispatcher blocked on this subscription.)
2132        self.unsubscribe_notifications(sub_id).await;
2133
2134        outcome.map_err(|_| format!("Extension '{}' command.invoke timed out", self.id))?
2135    }
2136
2137    async fn handle(&self, event: &HookEvent) -> HookResult {
2138        let params = serde_json::to_value(event).unwrap_or(Value::Null);
2139        match tokio::time::timeout(
2140            std::time::Duration::from_secs(5),
2141            self.call("hook.handle", params),
2142        )
2143        .await
2144        {
2145            Ok(Ok(value)) => match serde_json::from_value(value.clone()) {
2146                Ok(result) => result,
2147                Err(error) => {
2148                    tracing::warn!(
2149                        extension = %self.id,
2150                        error = %error,
2151                        response = %value,
2152                        "Extension hook handler returned invalid result",
2153                    );
2154                    match value.get("action").and_then(Value::as_str) {
2155                        // A malformed `modify` is dangerous — it would let an
2156                        // unknown/partial input through. Fail safe by blocking.
2157                        Some("modify") => HookResult::Block {
2158                            reason: "Extension returned malformed modify result".to_string(),
2159                        },
2160                        // A malformed `replace` is safe to ignore: the original
2161                        // tool output is preserved by emit_after_tool_call.
2162                        // Explicit arm so this intent can't be silently changed
2163                        // by a future edit to the catch-all below.
2164                        Some("replace") => HookResult::Continue,
2165                        _ => HookResult::Continue,
2166                    }
2167                }
2168            },
2169            Ok(Err(e)) => {
2170                tracing::warn!(
2171                    extension = %self.id,
2172                    error = %e,
2173                    "Extension hook handler failed — continuing",
2174                );
2175                HookResult::Continue
2176            }
2177            Err(_) => {
2178                tracing::warn!(
2179                    extension = %self.id,
2180                    timeout_secs = 5,
2181                    "Extension hook handler timed out — continuing",
2182                );
2183                HookResult::Continue
2184            }
2185        }
2186    }
2187
2188    async fn get_info(&self) -> Result<crate::extensions::info::PluginInfo, String> {
2189        let value = tokio::time::timeout(
2190            std::time::Duration::from_secs(5),
2191            self.call("info.get", Value::Null),
2192        )
2193        .await
2194        .map_err(|_| format!("Extension '{}' info.get timed out", self.id))??;
2195        serde_json::from_value(value).map_err(|e| {
2196            format!(
2197                "Invalid info.get response from extension '{}': {}",
2198                self.id, e
2199            )
2200        })
2201    }
2202
2203    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
2204        let value = tokio::time::timeout(
2205            std::time::Duration::from_secs(5),
2206            self.call("sidecar.spawn_args", Value::Null),
2207        )
2208        .await
2209        .map_err(|_| format!("Extension '{}' sidecar.spawn_args timed out", self.id))??;
2210        serde_json::from_value(value).map_err(|e| {
2211            format!(
2212                "Invalid sidecar.spawn_args response from extension '{}': {}",
2213                self.id, e
2214            )
2215        })
2216    }
2217
2218    async fn settings_editor_open(&self, category: &str, field: &str) -> Result<Value, String> {
2219        let params = crate::extensions::settings_editor::SettingsEditorOpenParams {
2220            category: category.to_string(),
2221            field: field.to_string(),
2222        };
2223        tokio::time::timeout(
2224            std::time::Duration::from_secs(5),
2225            self.call(
2226                "settings.editor.open",
2227                serde_json::to_value(params).map_err(|e| e.to_string())?,
2228            ),
2229        )
2230        .await
2231        .map_err(|_| format!("Extension '{}' settings.editor.open timed out", self.id))?
2232    }
2233
2234    async fn settings_editor_key(
2235        &self,
2236        category: &str,
2237        field: &str,
2238        key: &str,
2239    ) -> Result<Value, String> {
2240        let mut params = serde_json::to_value(
2241            crate::extensions::settings_editor::SettingsEditorKeyParams {
2242                key: key.to_string(),
2243            },
2244        )
2245        .map_err(|e| e.to_string())?;
2246        if let Some(obj) = params.as_object_mut() {
2247            obj.insert("category".to_string(), Value::String(category.to_string()));
2248            obj.insert("field".to_string(), Value::String(field.to_string()));
2249        }
2250        tokio::time::timeout(
2251            std::time::Duration::from_secs(5),
2252            self.call("settings.editor.key", params),
2253        )
2254        .await
2255        .map_err(|_| format!("Extension '{}' settings.editor.key timed out", self.id))?
2256    }
2257
2258    async fn settings_editor_commit(
2259        &self,
2260        category: &str,
2261        field: &str,
2262        value: Value,
2263    ) -> Result<Value, String> {
2264        let params = serde_json::json!({
2265            "category": category,
2266            "field": field,
2267            "value": value,
2268        });
2269        tokio::time::timeout(
2270            std::time::Duration::from_secs(5),
2271            self.call("settings.editor.commit", params),
2272        )
2273        .await
2274        .map_err(|_| format!("Extension '{}' settings.editor.commit timed out", self.id))?
2275    }
2276
2277    async fn shutdown(&self) {
2278        // Lease teardown is terminal for this ProcessExtension. In
2279        // particular, a concurrent force_shutdown may already have removed
2280        // the child; sending shutdown through the normal restart-capable
2281        // call path would then spawn an unvalidated replacement solely to
2282        // shut it down.
2283        let _ = tokio::time::timeout(
2284            std::time::Duration::from_millis(500),
2285            self.call_no_spawn("shutdown", Value::Null),
2286        )
2287        .await;
2288
2289        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2290        let mut state_guard = self.state.lock().await;
2291        if let Some(state) = state_guard.take() {
2292            state.reader_handle.abort();
2293            let mut child = state.child;
2294            let _ = child.kill().await;
2295        }
2296        // Drop all active notification subscribers and signal pending callers.
2297        self.inbox.notification_sinks.lock().await.clear();
2298        self.inbox
2299            .fail_all_pending("transport closed: extension shutdown")
2300            .await;
2301    }
2302
2303    async fn subscribe_notifications(
2304        &self,
2305    ) -> (usize, tokio::sync::mpsc::Receiver<NotificationFrame>) {
2306        ProcessExtension::subscribe_notifications(self).await
2307    }
2308
2309    async fn restart_count(&self) -> usize {
2310        self.restart_count()
2311    }
2312
2313    async fn health(&self) -> ExtensionHealth {
2314        // Exhaustion is judged on the consecutive-failure counter (resets on
2315        // successful restart); Degraded is judged on lifetime restarts so a
2316        // recovered-but-previously-crashed extension doesn't report Running.
2317        let consecutive = self.restart_count.load(Ordering::Relaxed);
2318        let lifetime = self.total_restarts.load(Ordering::Relaxed);
2319        let max = self.restart_policy.max_attempts as usize;
2320        if consecutive >= max {
2321            ExtensionHealth::Failed
2322        } else if lifetime > 0 {
2323            // Within budget. If the state slot is currently empty, we're
2324            // mid-restart; otherwise the process is alive but has previously
2325            // crashed at least once.
2326            let state_alive = self.state.try_lock().map(|g| g.is_some()).unwrap_or(true);
2327            if state_alive {
2328                ExtensionHealth::Degraded
2329            } else {
2330                ExtensionHealth::Restarting
2331            }
2332        } else {
2333            ExtensionHealth::Running
2334        }
2335    }
2336}
2337
2338#[cfg(test)]
2339mod stream_event_tests {
2340    use super::*;
2341    use serde_json::json;
2342
2343    #[test]
2344    fn parses_text_delta_with_delta_key() {
2345        let v = json!({"type": "text", "delta": "hi"});
2346        assert_eq!(
2347            parse_provider_stream_event(&v).unwrap(),
2348            ProviderStreamEvent::TextDelta { text: "hi".into() }
2349        );
2350    }
2351
2352    #[test]
2353    fn parses_text_delta_with_text_key() {
2354        let v = json!({"type": "text", "text": "hi"});
2355        assert_eq!(
2356            parse_provider_stream_event(&v).unwrap(),
2357            ProviderStreamEvent::TextDelta { text: "hi".into() }
2358        );
2359    }
2360
2361    #[test]
2362    fn parses_thinking_delta() {
2363        let v = json!({"type": "thinking", "delta": "hmm"});
2364        assert_eq!(
2365            parse_provider_stream_event(&v).unwrap(),
2366            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2367        );
2368        let v2 = json!({"type": "thinking", "text": "hmm"});
2369        assert_eq!(
2370            parse_provider_stream_event(&v2).unwrap(),
2371            ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2372        );
2373    }
2374
2375    #[test]
2376    fn parses_tool_use() {
2377        let v = json!({
2378            "type": "tool_use",
2379            "id": "t1",
2380            "name": "echo",
2381            "input": {"x": 1}
2382        });
2383        assert_eq!(
2384            parse_provider_stream_event(&v).unwrap(),
2385            ProviderStreamEvent::ToolUse {
2386                id: "t1".into(),
2387                name: "echo".into(),
2388                input: json!({"x": 1}),
2389            }
2390        );
2391    }
2392
2393    #[test]
2394    fn tool_use_input_defaults_to_empty_object() {
2395        let v = json!({"type": "tool_use", "id": "t1", "name": "echo"});
2396        assert_eq!(
2397            parse_provider_stream_event(&v).unwrap(),
2398            ProviderStreamEvent::ToolUse {
2399                id: "t1".into(),
2400                name: "echo".into(),
2401                input: json!({}),
2402            }
2403        );
2404    }
2405
2406    #[test]
2407    fn parses_usage_strips_type() {
2408        let v = json!({"type": "usage", "input_tokens": 5, "output_tokens": 7});
2409        assert_eq!(
2410            parse_provider_stream_event(&v).unwrap(),
2411            ProviderStreamEvent::Usage {
2412                usage: json!({"input_tokens": 5, "output_tokens": 7})
2413            }
2414        );
2415    }
2416
2417    #[test]
2418    fn parses_error() {
2419        let v = json!({"type": "error", "message": "boom"});
2420        assert_eq!(
2421            parse_provider_stream_event(&v).unwrap(),
2422            ProviderStreamEvent::Error {
2423                message: "boom".into()
2424            }
2425        );
2426    }
2427
2428    #[test]
2429    fn parses_done() {
2430        let v = json!({"type": "done"});
2431        assert_eq!(
2432            parse_provider_stream_event(&v).unwrap(),
2433            ProviderStreamEvent::Done
2434        );
2435    }
2436
2437    #[test]
2438    fn nested_event_shape_matches_flat() {
2439        let flat = json!({"type": "text", "delta": "hi"});
2440        let nested = json!({"event": {"type": "text", "delta": "hi"}});
2441        assert_eq!(
2442            parse_provider_stream_event(&flat).unwrap(),
2443            parse_provider_stream_event(&nested).unwrap()
2444        );
2445    }
2446
2447    #[test]
2448    fn missing_type_errors() {
2449        let v = json!({"delta": "hi"});
2450        let err = parse_provider_stream_event(&v).unwrap_err();
2451        assert!(err.contains("missing type"), "got: {err}");
2452    }
2453
2454    #[test]
2455    fn unknown_type_errors_with_type() {
2456        let v = json!({"type": "wat"});
2457        let err = parse_provider_stream_event(&v).unwrap_err();
2458        assert!(err.contains("wat"), "got: {err}");
2459    }
2460
2461    #[test]
2462    fn tool_use_missing_id_errors() {
2463        let v = json!({"type": "tool_use", "name": "echo"});
2464        let err = parse_provider_stream_event(&v).unwrap_err();
2465        assert!(err.contains("id"), "got: {err}");
2466    }
2467
2468    #[test]
2469    fn tool_use_missing_name_errors() {
2470        let v = json!({"type": "tool_use", "id": "t1"});
2471        let err = parse_provider_stream_event(&v).unwrap_err();
2472        assert!(err.contains("name"), "got: {err}");
2473    }
2474
2475    #[test]
2476    fn tool_use_empty_id_errors() {
2477        let v = json!({"type": "tool_use", "id": "", "name": "echo"});
2478        assert!(parse_provider_stream_event(&v).is_err());
2479    }
2480
2481    #[test]
2482    fn tool_use_empty_name_errors() {
2483        let v = json!({"type": "tool_use", "id": "t1", "name": ""});
2484        assert!(parse_provider_stream_event(&v).is_err());
2485    }
2486
2487    #[test]
2488    fn tool_use_non_object_input_errors() {
2489        let v = json!({"type": "tool_use", "id": "t1", "name": "echo", "input": "nope"});
2490        let err = parse_provider_stream_event(&v).unwrap_err();
2491        assert!(err.contains("input"), "got: {err}");
2492    }
2493
2494    #[test]
2495    fn text_missing_delta_and_text_errors() {
2496        let v = json!({"type": "text"});
2497        let err = parse_provider_stream_event(&v).unwrap_err();
2498        assert!(err.contains("delta") || err.contains("text"), "got: {err}");
2499    }
2500
2501    #[test]
2502    fn error_missing_message_errors() {
2503        let v = json!({"type": "error"});
2504        assert!(parse_provider_stream_event(&v).is_err());
2505    }
2506
2507    #[test]
2508    fn error_empty_message_errors() {
2509        let v = json!({"type": "error", "message": ""});
2510        assert!(parse_provider_stream_event(&v).is_err());
2511    }
2512}
2513
2514#[cfg(test)]
2515mod restart_policy_tests {
2516    use super::*;
2517
2518    #[tokio::test]
2519    async fn restart_policy_default_max_attempts_is_3() {
2520        // Use a command that won't actually run; we only need the struct.
2521        // Since `spawn` actually launches the process, use a trivial echoer
2522        // and immediately shut it down. Falls back to /bin/cat which reads
2523        // from stdin and stays alive.
2524        let ext = ProcessExtension::spawn("policy-test", "/bin/cat", &[])
2525            .await
2526            .expect("spawn /bin/cat");
2527        assert_eq!(ext.restart_policy.max_attempts, 3);
2528        ext.shutdown().await;
2529    }
2530
2531    #[tokio::test]
2532    async fn with_restart_policy_overrides_default() {
2533        let ext = ProcessExtension::spawn("policy-test-override", "/bin/cat", &[])
2534            .await
2535            .expect("spawn /bin/cat");
2536        let custom = RestartPolicy {
2537            max_attempts: 7,
2538            ..RestartPolicy::default()
2539        };
2540        let ext = ext.with_restart_policy(custom);
2541        assert_eq!(ext.restart_policy.max_attempts, 7);
2542        ext.shutdown().await;
2543    }
2544}
2545
2546#[cfg(test)]
2547mod capture_validator_tests {
2548    use super::*;
2549    use crate::extensions::permissions::{Permission, PermissionSet};
2550
2551    fn perms_with(grants: &[Permission]) -> PermissionSet {
2552        let mut p = PermissionSet::new();
2553        for g in grants {
2554            p.grant(*g);
2555        }
2556        p
2557    }
2558
2559    fn cap(kind: &str, name: &str, perms: &[&str]) -> CapabilityDeclaration {
2560        CapabilityDeclaration {
2561            kind: kind.to_string(),
2562            name: name.to_string(),
2563            permissions: perms.iter().map(|p| p.to_string()).collect(),
2564            params: serde_json::Value::Null,
2565        }
2566    }
2567
2568    #[test]
2569    fn capability_validator_rejects_empty_kind() {
2570        let d = cap("   ", "Sample", &["audio.input"]);
2571        let perms = perms_with(&[Permission::AudioInput]);
2572        let err = validate_capability(&d, &perms).unwrap_err();
2573        assert!(err.contains("kind"), "got: {}", err);
2574    }
2575
2576    #[test]
2577    fn capability_validator_rejects_empty_name() {
2578        let d = cap("capture", "   ", &["audio.input"]);
2579        let perms = perms_with(&[Permission::AudioInput]);
2580        let err = validate_capability(&d, &perms).unwrap_err();
2581        assert!(err.contains("name"), "got: {}", err);
2582    }
2583
2584    #[test]
2585    fn capability_validator_rejects_unknown_permission_string() {
2586        let d = cap("capture", "Sample", &["audio.telepathy"]);
2587        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2588        let err = validate_capability(&d, &perms).unwrap_err();
2589        assert!(
2590            err.contains("unknown permission") && err.contains("audio.telepathy"),
2591            "got: {}",
2592            err,
2593        );
2594    }
2595
2596    #[test]
2597    fn capability_validator_requires_every_declared_permission() {
2598        let d = cap("capture", "Sample", &["audio.input"]);
2599        let perms = perms_with(&[]);
2600        let err = validate_capability(&d, &perms).unwrap_err();
2601        assert!(
2602            err.contains("audio.input") && err.contains("not granted"),
2603            "got: {}",
2604            err,
2605        );
2606    }
2607
2608    #[test]
2609    fn capability_validator_accepts_when_all_permissions_granted() {
2610        let d = cap("capture", "Sample", &["audio.input", "audio.output"]);
2611        let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2612        validate_capability(&d, &perms).expect("should validate");
2613    }
2614
2615    #[test]
2616    fn capability_validator_accepts_no_permissions() {
2617        // Capabilities that genuinely don't need any permission (e.g. a
2618        // pure-CPU OCR sidecar that takes images via stdin) should
2619        // validate cleanly with an empty permission list.
2620        let d = cap("ocr", "Tesseract", &[]);
2621        let perms = perms_with(&[]);
2622        validate_capability(&d, &perms).expect("should validate");
2623    }
2624
2625    #[test]
2626    fn capability_validator_does_not_branch_on_kind() {
2627        // Two capabilities with identical permission requirements but
2628        // different `kind` values both validate identically — proves
2629        // the validator does not enumerate kinds.
2630        let perms = perms_with(&[Permission::AudioInput]);
2631        for kind in ["capture", "ocr", "agent", "foot_pedal", "eeg"] {
2632            let d = cap(kind, "Anything", &["audio.input"]);
2633            validate_capability(&d, &perms).expect("should validate");
2634        }
2635    }
2636}
2637
2638#[cfg(test)]
2639mod invoke_command_dispatch_tests {
2640    //! Phase B Phase 2/3 — exercise the notification dispatcher used by
2641    //! `command.invoke`. Spawning a real subprocess is not required: we feed
2642    //! `NotificationFrame`s through the production async forwarder into the
2643    //! bounded CP-11 fix-3 sink and assert the collected ordering.
2644    use super::*;
2645    use crate::extensions::commands::CommandOutputEvent;
2646    use crate::extensions::invoke_output::{invoke_event_channel, InvokeOutputBudget};
2647    use crate::extensions::runtime::InvokeCommandEvent;
2648    use crate::extensions::tasks::{TaskEvent, TaskKind};
2649    use serde_json::json;
2650
2651    fn frame(method: &str, params: serde_json::Value) -> NotificationFrame {
2652        NotificationFrame {
2653            method: method.to_string(),
2654            params,
2655        }
2656    }
2657
2658    /// Feed `frames` through the production forwarder into the bounded
2659    /// sink (the small frame counts here never exceed channel capacity,
2660    /// so sequential awaited sends cannot park), then collect. Returns
2661    /// the retained events and whether a Done marker was observed.
2662    async fn dispatch(frames: Vec<NotificationFrame>) -> (Vec<InvokeCommandEvent>, bool) {
2663        let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
2664        let mut open = true;
2665        let mut saw_done = false;
2666        for f in frames {
2667            saw_done |=
2668                ProcessExtension::forward_invoke_command_frame("ext-test", "r1", &tx, &mut open, f)
2669                    .await;
2670        }
2671        drop(tx);
2672        let report = collector.collect().await;
2673        (report.events, saw_done)
2674    }
2675
2676    #[tokio::test]
2677    async fn forwards_mixed_event_stream_in_order() {
2678        let frames = vec![
2679            frame(
2680                "command.output",
2681                json!({"request_id":"r1","event":{"kind":"text","content":"A"}}),
2682            ),
2683            frame(
2684                "task.start",
2685                json!({"id":"dl","label":"Downloading","kind":"download"}),
2686            ),
2687            frame("task.update", json!({"id":"dl","current":50,"total":100})),
2688            frame(
2689                "command.output",
2690                json!({"request_id":"r1","event":{"kind":"system","content":"working"}}),
2691            ),
2692            frame("task.done", json!({"id":"dl"})),
2693            frame(
2694                "command.output",
2695                json!({"request_id":"r1","event":{"kind":"done"}}),
2696            ),
2697        ];
2698
2699        let (events, saw_done) = dispatch(frames).await;
2700        assert!(saw_done, "should have observed the command Done marker");
2701        assert_eq!(events.len(), 6);
2702        assert_eq!(
2703            events[0],
2704            InvokeCommandEvent::Output(CommandOutputEvent::Text {
2705                content: "A".into()
2706            })
2707        );
2708        assert!(matches!(
2709            events[1],
2710            InvokeCommandEvent::Task(TaskEvent::Start {
2711                kind: TaskKind::Download,
2712                ..
2713            })
2714        ));
2715        assert!(matches!(
2716            events[2],
2717            InvokeCommandEvent::Task(TaskEvent::Update { .. })
2718        ));
2719        assert!(matches!(
2720            events[3],
2721            InvokeCommandEvent::Output(CommandOutputEvent::System { .. })
2722        ));
2723        assert!(matches!(
2724            events[4],
2725            InvokeCommandEvent::Task(TaskEvent::Done { error: None, .. })
2726        ));
2727        assert_eq!(
2728            events[5],
2729            InvokeCommandEvent::Output(CommandOutputEvent::Done)
2730        );
2731    }
2732
2733    #[tokio::test]
2734    async fn ignores_command_output_for_unrelated_request_id() {
2735        let (events, saw_done) = dispatch(vec![frame(
2736            "command.output",
2737            json!({"request_id":"other","event":{"kind":"text","content":"x"}}),
2738        )])
2739        .await;
2740        assert!(!saw_done);
2741        assert!(events.is_empty());
2742    }
2743
2744    #[tokio::test]
2745    async fn skips_malformed_command_output_without_aborting() {
2746        let (events, saw_done) = dispatch(vec![
2747            // Missing 'kind'
2748            frame("command.output", json!({"request_id":"r1","event":{}})),
2749            // Followed by a good event — must still be delivered.
2750            frame(
2751                "command.output",
2752                json!({"request_id":"r1","event":{"kind":"done"}}),
2753            ),
2754        ])
2755        .await;
2756        assert!(saw_done);
2757        assert_eq!(
2758            events,
2759            vec![InvokeCommandEvent::Output(CommandOutputEvent::Done)]
2760        );
2761    }
2762
2763    #[tokio::test]
2764    async fn task_events_pass_through_regardless_of_request_id() {
2765        let (events, _) = dispatch(vec![frame("task.log", json!({"id":"abc","line":"..."}))]).await;
2766        match &events[..] {
2767            [InvokeCommandEvent::Task(TaskEvent::Log { id, line })] => {
2768                assert_eq!(id, "abc");
2769                assert_eq!(line, "...");
2770            }
2771            other => panic!("unexpected: {other:?}"),
2772        }
2773    }
2774
2775    #[tokio::test]
2776    async fn unrelated_methods_are_dropped() {
2777        let (events, saw_done) = dispatch(vec![frame(
2778            "provider.stream.event",
2779            json!({"type":"text","delta":"x"}),
2780        )])
2781        .await;
2782        assert!(!saw_done);
2783        assert!(events.is_empty());
2784    }
2785
2786    /// A closed collector (dropped receiver) latches `sink_open` false and
2787    /// never blocks or aborts the dispatch loop.
2788    #[tokio::test]
2789    async fn closed_sink_latches_open_false_without_blocking() {
2790        let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
2791        drop(collector);
2792        let mut open = true;
2793        let saw_done = ProcessExtension::forward_invoke_command_frame(
2794            "ext-test",
2795            "r1",
2796            &tx,
2797            &mut open,
2798            frame(
2799                "command.output",
2800                json!({"request_id":"r1","event":{"kind":"done"}}),
2801            ),
2802        )
2803        .await;
2804        // Done detection is independent of sink state.
2805        assert!(saw_done);
2806        assert!(!open);
2807    }
2808}