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