Skip to main content

agent_engine/extensions/runtime/
process.rs

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