1use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8use std::process::Stdio;
9use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, ChildStdout, Command};
17use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
18use tokio::task::JoinHandle;
19
20use super::{ExtensionHandler, ExtensionHealth, RestartPolicy};
21use crate::extensions::hooks::events::{HookEvent, HookResult};
22use crate::extensions::manifest::CURRENT_EXTENSION_PROTOCOL_VERSION;
23
24#[derive(Serialize)]
25struct JsonRpcRequest {
26 jsonrpc: &'static str,
27 method: String,
28 params: Value,
29 id: u64,
30}
31
32#[derive(Serialize)]
33struct InitializeParams {
34 synaps_version: &'static str,
35 extension_protocol_version: u32,
36 plugin_id: String,
37 plugin_root: Option<String>,
38 config: Value,
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42pub struct RegisteredExtensionToolSpec {
43 pub name: String,
44 pub description: String,
45 pub input_schema: Value,
46}
47
48#[derive(Debug, Clone, PartialEq, Deserialize)]
49pub struct RegisteredProviderSpec {
50 pub id: String,
51 pub display_name: String,
52 pub description: String,
53 #[serde(default)]
54 pub models: Vec<RegisteredProviderModelSpec>,
55 #[serde(default)]
56 pub config_schema: Option<Value>,
57}
58
59#[derive(Debug, Clone, PartialEq, Deserialize)]
60pub struct RegisteredProviderModelSpec {
61 pub id: String,
62 #[serde(default)]
63 pub display_name: Option<String>,
64 #[serde(default)]
65 pub capabilities: Value,
66 #[serde(default)]
67 pub context_window: Option<u64>,
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct ProviderCompleteParams {
72 pub provider_id: String,
73 pub model_id: String,
74 pub model: String,
75 pub messages: Vec<crate::SharedMessage>,
76 pub system_prompt: Option<String>,
77 pub tools: Vec<Value>,
78 pub temperature: Option<f32>,
79 pub max_tokens: Option<u32>,
80 pub thinking_budget: u32,
81}
82
83#[derive(Debug, Clone, PartialEq, Deserialize)]
84pub struct ProviderCompleteResult {
85 pub content: Vec<Value>,
86 #[serde(default)]
87 pub stop_reason: Option<String>,
88 #[serde(default)]
89 pub usage: Option<Value>,
90}
91
92#[derive(Debug, Clone, PartialEq)]
93pub struct ProviderToolUse {
94 pub id: String,
95 pub name: String,
96 pub input: Value,
97}
98
99#[derive(Debug, Clone, PartialEq)]
101pub enum ProviderStreamEvent {
102 TextDelta { text: String },
104 ThinkingDelta { text: String },
106 ToolUse {
108 id: String,
109 name: String,
110 input: Value,
111 },
112 Usage { usage: Value },
114 Error { message: String },
116 Done,
118}
119
120pub 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 let authorized = match crate::tools::activation::ExecutionGate::authorize_wire_call(
240 registry,
241 session_tools,
242 &tool_name,
243 ) {
244 Ok(authorized) => authorized,
245 Err(denial) => {
246 return serde_json::json!({
247 "type": "tool_result",
248 "tool_use_id": tool_id,
249 "content": denial.to_string(),
250 "is_error": true,
251 });
252 }
253 };
254 let tool = authorized.implementation();
255
256 let runtime_name = authorized.runtime_name().to_string();
257 let input = registry.translate_input_for_api_tool(&tool_name, input);
258 let decision = crate::runtime::resolve_before_tool_call_decision(
259 input.clone(),
260 crate::runtime::emit_before_tool_call(
261 hook_bus,
262 &tool_name,
263 Some(&runtime_name),
264 input.clone(),
265 )
266 .await,
267 ctx.capabilities.secret_prompt.as_ref(),
268 false,
269 )
270 .await;
271
272 let crate::runtime::BeforeToolCallDecision::Continue { input } = decision else {
273 let crate::runtime::BeforeToolCallDecision::Block { reason } = decision else {
274 unreachable!()
275 };
276 return serde_json::json!({
277 "type": "tool_result",
278 "tool_use_id": tool_id,
279 "content": format!("Tool call blocked by extension: {}", reason),
280 "is_error": true,
281 });
282 };
283
284 let input_for_hook = input.clone();
285 let (result, is_error) = match tool.execute(input, ctx).await {
286 Ok(output) => (output, false),
287 Err(error) => (format!("Tool execution failed: {}", error), true),
288 };
289 let result = crate::runtime::emit_after_tool_call(
290 hook_bus,
291 &tool_name,
292 Some(&runtime_name),
293 input_for_hook,
294 result,
295 max_tool_output,
296 )
297 .await;
298
299 let mut response = serde_json::json!({
300 "type": "tool_result",
301 "tool_use_id": tool_id,
302 "content": crate::truncate_str(&result, max_tool_output).to_string(),
303 });
304 if is_error {
305 response["is_error"] = serde_json::json!(true);
306 }
307 response
308}
309
310#[allow(clippy::too_many_arguments)] pub async fn complete_provider_with_tools<F>(
317 handler: Arc<dyn ExtensionHandler>,
318 mut params: ProviderCompleteParams,
319 registry: &crate::ToolRegistry,
320 session_tools: &crate::tools::activation::SessionToolSet,
321 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
322 mut context_factory: F,
323 max_tool_output: usize,
324 max_iterations: usize,
325 tools_requested: &mut u32,
330) -> Result<ProviderCompleteResult, String>
331where
332 F: FnMut() -> crate::ToolContext,
333{
334 let max_iterations = max_iterations.max(1);
335 *tools_requested = 0;
336 for iteration in 0..max_iterations {
337 let result = handler.provider_complete(params.clone()).await?;
338 let tool_uses = extract_provider_tool_uses(&result.content)?;
339 if tool_uses.is_empty() {
340 return Ok(result);
341 }
342 *tools_requested = tools_requested.saturating_add(tool_uses.len() as u32);
345 if iteration + 1 == max_iterations {
346 return Err(format!(
347 "extension provider '{}' exceeded provider tool-use iteration limit ({})",
348 handler.id(),
349 max_iterations,
350 ));
351 }
352
353 let assistant_content = result.content.clone();
354 params.messages.push(std::sync::Arc::new(serde_json::json!({
355 "role": "assistant",
356 "content": assistant_content,
357 })));
358
359 let mut tool_results = Vec::with_capacity(tool_uses.len());
360 for tool_use in tool_uses {
361 tool_results.push(
362 execute_provider_tool_use(
363 registry,
364 session_tools,
365 hook_bus,
366 tool_use,
367 context_factory(),
368 max_tool_output,
369 )
370 .await,
371 );
372 }
373 params.messages.push(std::sync::Arc::new(serde_json::json!({
374 "role": "user",
375 "content": tool_results,
376 })));
377 }
378 Err(format!(
379 "extension provider '{}' exceeded provider tool-use iteration limit ({})",
380 handler.id(),
381 max_iterations,
382 ))
383}
384
385pub fn extract_provider_tool_uses(content: &[Value]) -> Result<Vec<ProviderToolUse>, String> {
386 let mut tool_uses = Vec::new();
387 for block in content {
388 if block.get("type").and_then(Value::as_str) != Some("tool_use") {
389 continue;
390 }
391 let id = block
392 .get("id")
393 .and_then(Value::as_str)
394 .ok_or_else(|| "provider tool_use missing id".to_string())?;
395 let name = block
396 .get("name")
397 .and_then(Value::as_str)
398 .ok_or_else(|| "provider tool_use missing name".to_string())?;
399 if id.trim().is_empty() {
400 return Err("provider tool_use id is empty".to_string());
401 }
402 if name.trim().is_empty() {
403 return Err("provider tool_use name is empty".to_string());
404 }
405 let input = block
406 .get("input")
407 .cloned()
408 .unwrap_or_else(|| serde_json::json!({}));
409 if !input.is_object() {
410 return Err(format!(
411 "provider tool_use '{}' input must be a JSON object",
412 id
413 ));
414 }
415 tool_uses.push(ProviderToolUse {
416 id: id.to_string(),
417 name: name.to_string(),
418 input,
419 });
420 }
421 Ok(tool_uses)
422}
423
424#[derive(Debug, Clone, Default, PartialEq)]
425pub struct InitializeCapabilitiesResult {
426 pub tools: Vec<RegisteredExtensionToolSpec>,
427 pub providers: Vec<RegisteredProviderSpec>,
428 pub capabilities: Vec<CapabilityDeclaration>,
434}
435
436#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
445pub struct CapabilityDeclaration {
446 pub kind: String,
450 pub name: String,
453 #[serde(default)]
458 pub permissions: Vec<String>,
459 #[serde(default, skip_serializing_if = "is_null_value")]
463 pub params: serde_json::Value,
464}
465
466fn is_null_value(v: &serde_json::Value) -> bool {
467 v.is_null()
468}
469
470pub fn validate_capability(
480 decl: &CapabilityDeclaration,
481 granted: &crate::extensions::permissions::PermissionSet,
482) -> Result<(), String> {
483 use crate::extensions::permissions::Permission;
484 if decl.kind.trim().is_empty() {
485 return Err("capability 'kind' must be non-empty".to_string());
486 }
487 if decl.name.trim().is_empty() {
488 return Err("capability 'name' must be non-empty".to_string());
489 }
490 for perm_name in &decl.permissions {
491 let parsed = Permission::parse(perm_name).ok_or_else(|| {
492 format!(
493 "capability '{}' declares unknown permission '{}'",
494 decl.kind, perm_name
495 )
496 })?;
497 if !granted.has(parsed) {
498 return Err(format!(
499 "capability '{}' requires permission '{}' but it is not granted",
500 decl.kind, perm_name
501 ));
502 }
503 }
504 Ok(())
505}
506
507#[derive(Deserialize)]
508struct InitializeResult {
509 protocol_version: u32,
510 #[serde(default)]
511 capabilities: InitializeCapabilities,
512}
513
514#[derive(Default, Deserialize)]
515struct InitializeCapabilities {
516 #[serde(default)]
517 tools: Vec<RegisteredExtensionToolSpec>,
518 #[serde(default)]
519 providers: Vec<RegisteredProviderSpec>,
520 #[serde(default)]
522 capabilities: Vec<CapabilityDeclaration>,
523}
524
525#[doc(hidden)]
531#[derive(Debug, Clone)]
532pub struct NotificationFrame {
533 pub method: String,
534 pub params: Value,
535}
536
537pub const NOTIFICATION_QUEUE_CAPACITY: usize = 8;
543pub const PROVIDER_EVENT_QUEUE_CAPACITY: usize = 8;
546
547struct Inbox {
554 pending: Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>,
555 notification_sinks: Mutex<Vec<(usize, mpsc::Sender<NotificationFrame>)>>,
563 next_sink_id: std::sync::atomic::AtomicUsize,
567 closed: std::sync::atomic::AtomicBool,
570 permissions: RwLock<Option<crate::extensions::permissions::PermissionSet>>,
573 inbound_stdin: Mutex<Option<Arc<Mutex<ChildStdin>>>>,
577 extension_id: String,
579}
580
581impl Inbox {
582 fn new(extension_id: String) -> Self {
583 Self {
584 pending: Mutex::new(HashMap::new()),
585 notification_sinks: Mutex::new(Vec::new()),
586 next_sink_id: std::sync::atomic::AtomicUsize::new(0),
587 closed: std::sync::atomic::AtomicBool::new(false),
588 permissions: RwLock::new(None),
589 inbound_stdin: Mutex::new(None),
590 extension_id,
591 }
592 }
593
594 async fn fail_all_pending(&self, reason: &str) {
597 self.closed
598 .store(true, std::sync::atomic::Ordering::Release);
599 let drained: Vec<_> = {
600 let mut pending = self.pending.lock().await;
601 pending.drain().collect()
602 };
603 for (_, tx) in drained {
604 let _ = tx.send(Err(reason.to_string()));
605 }
606 }
607}
608
609struct ProcessState {
610 child: Child,
611 stdin: Arc<Mutex<ChildStdin>>,
612 reader_handle: JoinHandle<()>,
613}
614
615pub struct ProcessExtension {
617 id: String,
618 command: String,
619 args: Vec<String>,
620 cwd: Option<PathBuf>,
621 state: Arc<Mutex<Option<ProcessState>>>,
622 call_lock: Arc<Mutex<()>>,
624 next_id: AtomicU64,
625 restart_count: AtomicUsize,
626 initialize_config: Mutex<Value>,
630 total_restarts: AtomicUsize,
636 pub(crate) restart_policy: RestartPolicy,
638 inbox: Arc<Inbox>,
642}
643
644impl ProcessExtension {
645 pub async fn force_shutdown(&self) {
649 let mut state_guard = self.state.lock().await;
650 if let Some(state) = state_guard.take() {
651 state.reader_handle.abort();
652 let mut child = state.child;
653 let _ = child.kill().await;
654 }
655 drop(state_guard);
656 self.inbox.closed.store(true, Ordering::Release);
657 self.inbox.notification_sinks.lock().await.clear();
658 self.inbox
659 .fail_all_pending("transport closed: extension cancelled")
660 .await;
661 }
662
663 pub async fn spawn(id: &str, command: &str, args: &[String]) -> Result<Self, String> {
664 Self::spawn_with_cwd(id, command, args, None).await
665 }
666
667 pub async fn spawn_with_cwd(
672 id: &str,
673 command: &str,
674 args: &[String],
675 cwd: Option<PathBuf>,
676 ) -> Result<Self, String> {
677 let inbox = Arc::new(Inbox::new(id.to_string()));
678 let state = Self::spawn_state(id, command, args, cwd.as_ref(), inbox.clone()).await?;
679 Ok(Self {
680 id: id.to_string(),
681 command: command.to_string(),
682 args: args.to_vec(),
683 cwd,
684 state: Arc::new(Mutex::new(Some(state))),
685 call_lock: Arc::new(Mutex::new(())),
686 next_id: AtomicU64::new(1),
687 restart_count: AtomicUsize::new(0),
688 initialize_config: Mutex::new(Value::Object(Default::default())),
689 total_restarts: AtomicUsize::new(0),
690 restart_policy: RestartPolicy::default(),
691 inbox,
692 })
693 }
694
695 pub fn with_restart_policy(mut self, policy: RestartPolicy) -> Self {
697 self.restart_policy = policy;
698 self
699 }
700
701 async fn spawn_state(
702 id: &str,
703 command: &str,
704 args: &[String],
705 cwd: Option<&PathBuf>,
706 inbox: Arc<Inbox>,
707 ) -> Result<ProcessState, String> {
708 let mut cmd = Command::new(command);
709 cmd.args(args)
710 .stdin(Stdio::piped())
711 .stdout(Stdio::piped())
712 .stderr(Stdio::piped());
713 if let Some(cwd) = cwd {
714 cmd.current_dir(cwd);
715 }
716
717 cmd.env_clear();
723 for var in &["PATH", "HOME", "LANG", "TERM", "XDG_RUNTIME_DIR"] {
724 if let Ok(val) = std::env::var(var) {
725 cmd.env(var, val);
726 }
727 }
728
729 cmd.kill_on_drop(true);
730
731 let mut child = cmd
732 .spawn()
733 .map_err(|e| format!("Failed to spawn extension '{}': {}", id, e))?;
734
735 let stdin = child
736 .stdin
737 .take()
738 .ok_or_else(|| format!("No stdin for extension '{}'", id))?;
739 let stdout = child
740 .stdout
741 .take()
742 .ok_or_else(|| format!("No stdout for extension '{}'", id))?;
743 if let Some(stderr) = child.stderr.take() {
744 let extension_id = id.to_string();
745 tokio::spawn(async move {
746 const MAX_STDERR_LINE: usize = 8 * 1024;
751 const MAX_STDERR_FORWARD_TOTAL: u64 = 128 * 1024;
752 let mut reader = BufReader::new(stderr);
753 let mut forwarded: u64 = 0;
754 let mut suppressed: u64 = 0;
755 loop {
756 let mut buf: Vec<u8> = Vec::new();
757 let mut limited =
758 tokio::io::AsyncReadExt::take(&mut reader, MAX_STDERR_LINE as u64 + 1);
759 match tokio::io::AsyncBufReadExt::read_until(&mut limited, b'\n', &mut buf)
760 .await
761 {
762 Ok(0) => break,
763 Ok(n) => {
764 if forwarded < MAX_STDERR_FORWARD_TOTAL {
765 forwarded = forwarded.saturating_add(n as u64);
766 let truncated = n > MAX_STDERR_LINE;
767 let end = buf.len().min(MAX_STDERR_LINE);
768 let line = String::from_utf8_lossy(&buf[..end]);
769 let line = line.trim_end_matches(['\r', '\n']);
770 tracing::debug!(
771 extension = %extension_id,
772 truncated,
773 stderr = %line,
774 );
775 } else {
776 suppressed = suppressed.saturating_add(n as u64);
777 }
778 }
779 Err(error) => {
780 tracing::debug!(
781 extension = %extension_id,
782 error = %error,
783 "Failed to read extension stderr",
784 );
785 break;
786 }
787 }
788 }
789 if suppressed > 0 {
790 tracing::debug!(
791 extension = %extension_id,
792 suppressed_bytes = suppressed,
793 "Extension stderr exceeded the forwarding budget; further bytes counted only",
794 );
795 }
796 });
797 }
798
799 let reader_handle = Self::spawn_reader(stdout, inbox.clone(), id.to_string());
800
801 let stdin_arc = Arc::new(Mutex::new(stdin));
802 *inbox.inbound_stdin.lock().await = Some(stdin_arc.clone());
805
806 Ok(ProcessState {
807 child,
808 stdin: stdin_arc,
809 reader_handle,
810 })
811 }
812
813 fn spawn_reader(
818 stdout: ChildStdout,
819 inbox: Arc<Inbox>,
820 extension_id: String,
821 ) -> JoinHandle<()> {
822 tokio::spawn(async move {
823 let mut reader = BufReader::new(stdout);
824 loop {
825 match Self::read_one_frame(&mut reader, &extension_id).await {
826 Ok(Some(value)) => {
827 Self::dispatch_frame(value, &inbox, &extension_id).await;
828 }
829 Ok(None) => {
830 tracing::debug!(
831 extension = %extension_id,
832 "Extension stdout closed (EOF); failing pending requests",
833 );
834 inbox.fail_all_pending("transport closed: EOF").await;
835 inbox.notification_sinks.lock().await.clear();
837 return;
838 }
839 Err(error) => {
840 tracing::debug!(
841 extension = %extension_id,
842 error = %error,
843 "Extension transport read error",
844 );
845 inbox
846 .fail_all_pending(&format!("transport error: {}", error))
847 .await;
848 inbox.notification_sinks.lock().await.clear();
849 return;
850 }
851 }
852 }
853 })
854 }
855
856 async fn read_one_frame(
860 reader: &mut BufReader<ChildStdout>,
861 extension_id: &str,
862 ) -> Result<Option<Value>, String> {
863 let mut content_length: Option<usize> = None;
864 let mut saw_any_header = false;
865 loop {
866 let mut header_line = String::new();
867 let n = {
870 let mut limited = tokio::io::AsyncReadExt::take(&mut *reader, 1025);
871 limited
872 .read_line(&mut header_line)
873 .await
874 .map_err(|e| format!("Read header error: {}", e))?
875 };
876 if n == 0 {
877 if saw_any_header {
878 return Err("Unexpected EOF while reading response headers".into());
879 }
880 return Ok(None);
881 }
882 saw_any_header = true;
883 if header_line.len() > 1024 {
884 return Err(format!(
885 "Extension '{}' header line too long ({} bytes)",
886 extension_id,
887 header_line.len()
888 ));
889 }
890 let trimmed = header_line.trim();
891 if trimmed.is_empty() {
892 break;
893 }
894 if let Some((name, value)) = trimmed.split_once(':') {
895 if name.trim().eq_ignore_ascii_case("Content-Length") {
896 content_length = Some(value.trim().parse().map_err(|_| {
897 format!("Invalid Content-Length value: {:?}", value.trim())
898 })?);
899 }
900 }
901 }
902 let content_length = content_length.ok_or_else(|| {
903 format!(
904 "Extension '{}' frame missing Content-Length header",
905 extension_id
906 )
907 })?;
908 const MAX_RESPONSE_SIZE: usize = 4 * 1024 * 1024;
909 if content_length > MAX_RESPONSE_SIZE {
910 return Err(format!(
911 "Extension '{}' frame too large: {} bytes (max {})",
912 extension_id, content_length, MAX_RESPONSE_SIZE
913 ));
914 }
915 let mut buf = vec![0u8; content_length];
916 tokio::io::AsyncReadExt::read_exact(reader, &mut buf)
917 .await
918 .map_err(|e| format!("Read body error: {}", e))?;
919 let value: Value =
920 serde_json::from_slice(&buf).map_err(|e| format!("Parse frame error: {}", e))?;
921 Ok(Some(value))
922 }
923
924 async fn dispatch_frame(value: Value, inbox: &Arc<Inbox>, extension_id: &str) {
930 let id_field = value.get("id");
931 let id_is_present = !matches!(id_field, None | Some(Value::Null));
932 let method_field = value
933 .get("method")
934 .and_then(Value::as_str)
935 .map(str::to_string);
936
937 if id_is_present && method_field.is_some() {
938 let id = match id_field {
950 Some(id @ (Value::String(_) | Value::Number(_))) => id.clone(),
951 _ => {
952 tracing::warn!(
953 extension = %extension_id,
954 frame = %value,
955 "Discarding inbound request: JSON-RPC id must be a string or a number",
956 );
957 return;
958 }
959 };
960 let Some(method) = method_field else { return };
961 let params = value.get("params").cloned().unwrap_or(Value::Null);
962 let inbox = inbox.clone();
963 let extension_id = extension_id.to_string();
964 tokio::spawn(async move {
965 let outcome = Self::handle_inbound_request(&inbox, &method, params).await;
966 let payload = match outcome {
967 Ok(result) => serde_json::json!({
968 "jsonrpc": "2.0",
969 "id": id,
970 "result": result,
971 }),
972 Err((code, message)) => serde_json::json!({
973 "jsonrpc": "2.0",
974 "id": id,
975 "error": {"code": code, "message": message},
976 }),
977 };
978 let stdin_handle = inbox.inbound_stdin.lock().await.clone();
979 if let Some(stdin) = stdin_handle {
980 let body = match serde_json::to_string(&payload) {
981 Ok(s) => s,
982 Err(error) => {
983 tracing::warn!(
984 extension = %extension_id,
985 error = %error,
986 "Failed to serialize inbound response",
987 );
988 return;
989 }
990 };
991 let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
992 let mut stdin = stdin.lock().await;
993 if let Err(error) = stdin.write_all(frame.as_bytes()).await {
994 tracing::warn!(
995 extension = %extension_id,
996 error = %error,
997 "Failed to write inbound response",
998 );
999 return;
1000 }
1001 if let Err(error) = stdin.flush().await {
1002 tracing::warn!(
1003 extension = %extension_id,
1004 error = %error,
1005 "Failed to flush inbound response",
1006 );
1007 }
1008 } else {
1009 tracing::warn!(
1010 extension = %extension_id,
1011 "No stdin available to reply to inbound request",
1012 );
1013 }
1014 });
1015 return;
1016 }
1017
1018 if id_is_present {
1019 let id = match id_field.and_then(Value::as_u64) {
1024 Some(id) => id,
1025 None => {
1026 tracing::warn!(
1027 extension = %extension_id,
1028 frame = %value,
1029 "Discarding response frame: id does not match any request \
1030 the runtime issued (runtime request ids are always numeric)",
1031 );
1032 return;
1033 }
1034 };
1035 let sender = inbox.pending.lock().await.remove(&id);
1036 match sender {
1037 Some(tx) => {
1038 let payload = if let Some(err) = value.get("error") {
1039 let message = err
1040 .get("message")
1041 .and_then(Value::as_str)
1042 .unwrap_or("unknown extension error")
1043 .to_string();
1044 Err(format!("Extension error: {}", message))
1045 } else {
1046 Ok(value.get("result").cloned().unwrap_or(Value::Null))
1047 };
1048 let _ = tx.send(payload);
1049 }
1050 None => {
1051 tracing::trace!(
1052 extension = %extension_id,
1053 id = id,
1054 "Response with unknown id (no pending request); dropping",
1055 );
1056 }
1057 }
1058 } else if let Some(method) = value.get("method").and_then(Value::as_str) {
1059 let params = value.get("params").cloned().unwrap_or(Value::Null);
1060 let frame = NotificationFrame {
1061 method: method.to_string(),
1062 params,
1063 };
1064 let targets: Vec<(usize, mpsc::Sender<NotificationFrame>)> = {
1068 let sinks = inbox.notification_sinks.lock().await;
1069 sinks.to_vec()
1070 };
1071 if targets.is_empty() {
1072 tracing::trace!(
1073 extension = %extension_id,
1074 method = %method,
1075 "Notification with no active subscribers; dropping",
1076 );
1077 } else {
1078 let mut dead = Vec::new();
1083 for (id, tx) in &targets {
1084 if tx.send(frame.clone()).await.is_err() {
1085 dead.push(*id);
1086 }
1087 }
1088 if !dead.is_empty() {
1089 let mut sinks = inbox.notification_sinks.lock().await;
1090 sinks.retain(|(id, _)| !dead.contains(id));
1091 }
1092 }
1093 } else {
1094 tracing::trace!(
1095 extension = %extension_id,
1096 frame = %value,
1097 "Unrecognized frame; dropping",
1098 );
1099 }
1100 }
1101
1102 pub fn restart_count(&self) -> usize {
1103 self.total_restarts.load(Ordering::Relaxed)
1106 }
1107
1108 pub async fn set_permissions(&self, perms: crate::extensions::permissions::PermissionSet) {
1111 *self.inbox.permissions.write().await = Some(perms);
1112 }
1113
1114 #[allow(clippy::doc_lazy_continuation)]
1122 async fn handle_inbound_request(
1123 inbox: &Arc<Inbox>,
1124 method: &str,
1125 params: Value,
1126 ) -> Result<Value, (i32, String)> {
1127 use crate::extensions::permissions::Permission;
1128 use crate::memory::store::{self, MemoryQuery};
1129
1130 match method {
1131 "memory.append" => {
1132 Self::require_permission(inbox, Permission::MemoryWrite, "memory.write").await?;
1133 let namespace = Self::param_str(¶ms, "namespace")?;
1134 Self::require_namespace_matches(inbox, &namespace).await?;
1135 let content = Self::param_str(¶ms, "content")?;
1136 let tags = match params.get("tags") {
1137 None | Some(Value::Null) => Vec::new(),
1138 Some(Value::Array(arr)) => {
1139 let mut out = Vec::with_capacity(arr.len());
1140 for v in arr {
1141 match v.as_str() {
1142 Some(s) => out.push(s.to_string()),
1143 None => {
1144 return Err((
1145 -32602,
1146 "tags must be an array of strings".to_string(),
1147 ))
1148 }
1149 }
1150 }
1151 out
1152 }
1153 _ => return Err((-32602, "tags must be an array of strings".to_string())),
1154 };
1155 let meta = match params.get("meta") {
1156 None | Some(Value::Null) => None,
1157 Some(v) => Some(v.clone()),
1158 };
1159 let record = store::new_record(namespace, content, tags, meta);
1160 let timestamp_ms = record.timestamp_ms;
1161 store::append(&record).map_err(|e| (-32000, e.to_string()))?;
1162 Ok(serde_json::json!({"ok": true, "timestamp_ms": timestamp_ms}))
1163 }
1164 "memory.query" => {
1165 Self::require_permission(inbox, Permission::MemoryRead, "memory.read").await?;
1166 let namespace = Self::param_str(¶ms, "namespace")?;
1167 Self::require_namespace_matches(inbox, &namespace).await?;
1168 let q = MemoryQuery {
1169 content_contains: params
1170 .get("content_contains")
1171 .and_then(Value::as_str)
1172 .map(str::to_string),
1173 tag_prefix: params
1174 .get("tag_prefix")
1175 .and_then(Value::as_str)
1176 .map(str::to_string),
1177 since_ms: params.get("since_ms").and_then(Value::as_u64),
1178 until_ms: params.get("until_ms").and_then(Value::as_u64),
1179 limit: params
1180 .get("limit")
1181 .and_then(Value::as_u64)
1182 .map(|n| n as usize),
1183 };
1184 let records = store::query(&namespace, &q).map_err(|e| (-32000, e.to_string()))?;
1185 Ok(serde_json::json!({"records": records}))
1186 }
1187 "config.get" => {
1188 let key = Self::param_str(¶ms, "key")?;
1189 Self::validate_config_key(&key)?;
1190 let value =
1191 crate::extensions::config_store::read_plugin_config(&inbox.extension_id, &key);
1192 Ok(serde_json::json!({"value": value}))
1193 }
1194 "config.set" => {
1195 Self::require_permission(inbox, Permission::ConfigWrite, "config.write").await?;
1196 let key = Self::param_str(¶ms, "key")?;
1197 Self::validate_config_key(&key)?;
1198 let value = Self::param_str(¶ms, "value")?;
1199 crate::extensions::config_store::write_plugin_config(
1200 &inbox.extension_id,
1201 &key,
1202 &value,
1203 )
1204 .map_err(|e| (-32000, e.to_string()))?;
1205 Ok(serde_json::json!({"ok": true}))
1206 }
1207 "config.subscribe" => {
1208 Self::require_permission(inbox, Permission::ConfigSubscribe, "config.subscribe")
1209 .await?;
1210 Ok(serde_json::json!({"ok": true}))
1214 }
1215 other => Err((-32601, format!("method not found: {other}"))),
1216 }
1217 }
1218
1219 async fn require_permission(
1220 inbox: &Arc<Inbox>,
1221 perm: crate::extensions::permissions::Permission,
1222 wire: &str,
1223 ) -> Result<(), (i32, String)> {
1224 let guard = inbox.permissions.read().await;
1225 match guard.as_ref() {
1226 Some(set) if set.has(perm) => Ok(()),
1227 _ => Err((-32602, format!("permission denied: {wire} required"))),
1228 }
1229 }
1230
1231 async fn require_namespace_matches(
1232 inbox: &Arc<Inbox>,
1233 namespace: &str,
1234 ) -> Result<(), (i32, String)> {
1235 if namespace == inbox.extension_id {
1236 Ok(())
1237 } else {
1238 Err((
1239 -32602,
1240 format!(
1241 "namespace must equal extension id '{}' (got '{}')",
1242 inbox.extension_id, namespace
1243 ),
1244 ))
1245 }
1246 }
1247
1248 fn param_str(params: &Value, name: &str) -> Result<String, (i32, String)> {
1249 params
1250 .get(name)
1251 .and_then(Value::as_str)
1252 .map(str::to_string)
1253 .ok_or_else(|| (-32602, format!("missing or invalid '{name}' parameter")))
1254 }
1255
1256 fn validate_config_key(key: &str) -> Result<(), (i32, String)> {
1257 let trimmed = key.trim();
1258 if trimmed.is_empty() {
1259 return Err((-32602, "config key must be non-empty".to_string()));
1260 }
1261 if trimmed.contains('.') || trimmed.contains('/') || trimmed.contains(' ') {
1262 return Err((
1263 -32602,
1264 "config key must not contain dots, slashes, or spaces".to_string(),
1265 ));
1266 }
1267 Ok(())
1268 }
1269
1270 pub async fn initialize(
1271 &self,
1272 plugin_root: Option<PathBuf>,
1273 config: Value,
1274 ) -> Result<InitializeCapabilitiesResult, String> {
1275 *self.initialize_config.lock().await = config.clone();
1278 let params = InitializeParams {
1279 synaps_version: env!("CARGO_PKG_VERSION"),
1280 extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1281 plugin_id: self.id.clone(),
1282 plugin_root: plugin_root
1283 .or_else(|| self.cwd.clone())
1284 .map(|path| path.to_string_lossy().to_string()),
1285 config,
1286 };
1287 let value = self
1288 .call_no_restart(
1289 "initialize",
1290 serde_json::to_value(params).map_err(|e| e.to_string())?,
1291 )
1292 .await?;
1293 Self::parse_initialize_result(&self.id, value)
1294 }
1295
1296 fn parse_initialize_result(
1297 id: &str,
1298 value: Value,
1299 ) -> Result<InitializeCapabilitiesResult, String> {
1300 let result: InitializeResult = serde_json::from_value(value)
1301 .map_err(|e| format!("Invalid initialize response from extension '{}': {}", id, e))?;
1302 if result.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
1303 return Err(format!(
1304 "Extension '{}' initialize returned unsupported protocol_version {} (supported: {})",
1305 id, result.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
1306 ));
1307 }
1308 Self::validate_registered_tool_specs(id, &result.capabilities.tools)?;
1309 Self::validate_registered_provider_specs(id, &result.capabilities.providers)?;
1310 Ok(InitializeCapabilitiesResult {
1311 tools: result.capabilities.tools,
1312 providers: result.capabilities.providers,
1313 capabilities: result.capabilities.capabilities,
1314 })
1315 }
1316
1317 fn validate_registered_tool_specs(
1318 id: &str,
1319 tools: &[RegisteredExtensionToolSpec],
1320 ) -> Result<(), String> {
1321 use crate::extensions::validation::{validate_id_segment, IdValidationError};
1322 let mut names = HashSet::new();
1323 for tool in tools {
1324 let name = tool.name.trim();
1325 if let Err(err) = validate_id_segment(name) {
1326 return Err(match err {
1327 IdValidationError::Empty => format!(
1328 "Extension '{}' registered a tool with an empty tool name",
1329 id
1330 ),
1331 IdValidationError::ContainsReserved { ch } => format!(
1332 "Extension '{}' registered tool '{}' with invalid tool name: '{}' is reserved",
1333 id, name, ch
1334 ),
1335 IdValidationError::TooLong { len, max } => format!(
1336 "Extension '{}' registered tool '{}' with invalid tool name: must be at most {} chars (got {})",
1337 id, name, max, len
1338 ),
1339 IdValidationError::ContainsWhitespace => format!(
1340 "Extension '{}' registered tool '{}' with invalid tool name: must not contain whitespace",
1341 id, name
1342 ),
1343 IdValidationError::ContainsControl { ch } => format!(
1344 "Extension '{}' registered tool '{}' with invalid tool name: contains control character U+{:04X}",
1345 id, name, ch as u32
1346 ),
1347 });
1348 }
1349 if !names.insert(name.to_string()) {
1350 return Err(format!(
1351 "Extension '{}' registered duplicate tool name '{}'",
1352 id, name
1353 ));
1354 }
1355 if tool.description.trim().is_empty() {
1356 return Err(format!(
1357 "Extension '{}' registered tool '{}' with an empty description",
1358 id, name,
1359 ));
1360 }
1361 if !tool.input_schema.is_object() {
1362 return Err(format!(
1363 "Extension '{}' registered tool '{}' with invalid input_schema: input_schema must be a JSON object",
1364 id, name,
1365 ));
1366 }
1367 }
1368 Ok(())
1369 }
1370
1371 fn validate_registered_provider_specs(
1372 id: &str,
1373 providers: &[RegisteredProviderSpec],
1374 ) -> Result<(), String> {
1375 use crate::extensions::validation::{validate_id_segment, IdValidationError};
1376 for provider in providers {
1377 let provider_id = provider.id.trim();
1378 match validate_id_segment(provider_id) {
1379 Ok(()) => {
1380 if !Self::is_safe_provider_id(provider_id) {
1381 return Err(format!(
1382 "Extension '{}' registered provider '{}' with invalid provider id",
1383 id, provider_id
1384 ));
1385 }
1386 }
1387 Err(IdValidationError::Empty) => {
1388 return Err(format!(
1389 "Extension '{}' registered provider with empty provider id",
1390 id
1391 ));
1392 }
1393 Err(err) => {
1394 return Err(format!(
1395 "Extension '{}' registered provider '{}' with invalid provider id: {}",
1396 id, provider_id, err
1397 ));
1398 }
1399 }
1400 if provider.display_name.trim().is_empty() {
1401 return Err(format!(
1402 "Extension '{}' registered provider '{}' with empty display_name",
1403 id, provider_id,
1404 ));
1405 }
1406 if provider.description.trim().is_empty() {
1407 return Err(format!(
1408 "Extension '{}' registered provider '{}' with empty description",
1409 id, provider_id,
1410 ));
1411 }
1412 if provider.models.is_empty() {
1413 return Err(format!(
1414 "Extension '{}' registered provider '{}' must declare at least one model",
1415 id, provider_id,
1416 ));
1417 }
1418 let mut model_ids = HashSet::new();
1419 for model in &provider.models {
1420 let model_id = model.id.trim();
1421 if let Err(err) = validate_id_segment(model_id) {
1422 return Err(match err {
1423 IdValidationError::Empty => format!(
1424 "Extension '{}' registered provider '{}' with empty model id",
1425 id, provider_id
1426 ),
1427 IdValidationError::ContainsReserved { ch } => format!(
1428 "Extension '{}' registered provider '{}' with invalid model id '{}': '{}' is reserved",
1429 id, provider_id, model_id, ch
1430 ),
1431 IdValidationError::TooLong { len, max } => format!(
1432 "Extension '{}' registered provider '{}' with invalid model id '{}': must be at most {} chars (got {})",
1433 id, provider_id, model_id, max, len
1434 ),
1435 IdValidationError::ContainsWhitespace => format!(
1436 "Extension '{}' registered provider '{}' with invalid model id '{}': must not contain whitespace",
1437 id, provider_id, model_id
1438 ),
1439 IdValidationError::ContainsControl { ch } => format!(
1440 "Extension '{}' registered provider '{}' with invalid model id '{}': contains control character U+{:04X}",
1441 id, provider_id, model_id, ch as u32
1442 ),
1443 });
1444 }
1445 if !model_ids.insert(model_id.to_string()) {
1446 return Err(format!(
1447 "Extension '{}' registered provider '{}' with duplicate model id '{}'",
1448 id, provider_id, model_id,
1449 ));
1450 }
1451 }
1452 if let Some(config_schema) = &provider.config_schema {
1453 if !config_schema.is_object() {
1454 return Err(format!(
1455 "Extension '{}' registered provider '{}' with invalid config_schema: config_schema must be a JSON object",
1456 id, provider_id,
1457 ));
1458 }
1459 }
1460 }
1461 Ok(())
1462 }
1463
1464 fn is_safe_provider_id(id: &str) -> bool {
1465 !id.is_empty()
1466 && !id.contains(':')
1467 && id
1468 .chars()
1469 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1470 }
1471
1472 #[doc(hidden)]
1473 pub async fn initialize_for_test(&self, plugin_root: Option<PathBuf>) -> Result<(), String> {
1474 self.initialize(plugin_root, Value::Object(Default::default()))
1475 .await
1476 .map(|_| ())
1477 }
1478
1479 async fn restart_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1480 let attempted = self.restart_count.fetch_add(1, Ordering::Relaxed) + 1;
1481 self.total_restarts.fetch_add(1, Ordering::Relaxed);
1482 let max_attempts = self.restart_policy.max_attempts;
1483 if attempted > max_attempts as usize {
1484 *state = None;
1485 return Err(format!(
1486 "Extension '{}' exceeded restart limit ({})",
1487 self.id, max_attempts,
1488 ));
1489 }
1490
1491 if let Some(old) = state.take() {
1492 old.reader_handle.abort();
1493 let mut child = old.child;
1494 let _ = child.kill().await;
1495 }
1496 self.inbox
1498 .fail_all_pending("transport closed: process restarting")
1499 .await;
1500
1501 let delay = self
1502 .restart_policy
1503 .delay_for_attempt(attempted as u32)
1504 .unwrap_or_default();
1505
1506 tracing::warn!(
1507 extension = %self.id,
1508 attempt = attempted,
1509 max_attempts = max_attempts,
1510 delay_ms = delay.as_millis() as u64,
1511 "Restarting extension process after transport failure",
1512 );
1513
1514 if !delay.is_zero() {
1515 tokio::time::sleep(delay).await;
1516 }
1517
1518 *state = Some(
1519 Self::spawn_state(
1520 &self.id,
1521 &self.command,
1522 &self.args,
1523 self.cwd.as_ref(),
1524 self.inbox.clone(),
1525 )
1526 .await?,
1527 );
1528 self.inbox
1530 .closed
1531 .store(false, std::sync::atomic::Ordering::Release);
1532 self.initialize_locked(state).await?;
1533 Ok(())
1540 }
1541
1542 async fn initialize_locked(&self, state: &mut Option<ProcessState>) -> Result<(), String> {
1543 let config = self.initialize_config.lock().await.clone();
1544 let params = InitializeParams {
1545 synaps_version: env!("CARGO_PKG_VERSION"),
1546 extension_protocol_version: CURRENT_EXTENSION_PROTOCOL_VERSION,
1547 plugin_id: self.id.clone(),
1548 plugin_root: self
1549 .cwd
1550 .clone()
1551 .map(|path| path.to_string_lossy().to_string()),
1552 config,
1553 };
1554 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1555 let value = tokio::time::timeout(
1556 std::time::Duration::from_secs(10),
1557 self.call_once_locked(
1558 state.as_mut().expect("state should exist for initialize"),
1559 "initialize",
1560 serde_json::to_value(params).map_err(|e| e.to_string())?,
1561 id,
1562 ),
1563 )
1564 .await
1565 .map_err(|_| format!("Extension '{}' initialize timed out after 10s", self.id))??;
1566 Self::parse_initialize_result(&self.id, value).map(|_| ())
1567 }
1568
1569 async fn call_once_locked(
1573 &self,
1574 state: &mut ProcessState,
1575 method: &str,
1576 params: Value,
1577 id: u64,
1578 ) -> Result<Value, String> {
1579 let body = serde_json::to_string(&JsonRpcRequest {
1580 jsonrpc: "2.0",
1581 method: method.to_string(),
1582 params,
1583 id,
1584 })
1585 .map_err(|e| format!("Serialize error: {}", e))?;
1586
1587 let (tx, rx) = oneshot::channel::<Result<Value, String>>();
1588 if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1591 return Err("transport closed: inbox is shut down".to_string());
1592 }
1593
1594 self.inbox.pending.lock().await.insert(id, tx);
1597
1598 if self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1601 self.inbox.pending.lock().await.remove(&id);
1602 return Err("transport closed: inbox shut down during registration".to_string());
1603 }
1604
1605 let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
1606 let write_result = {
1607 let mut stdin = state.stdin.lock().await;
1608 match stdin.write_all(frame.as_bytes()).await {
1609 Ok(()) => stdin.flush().await,
1610 Err(e) => Err(e),
1611 }
1612 };
1613 if let Err(e) = write_result {
1614 self.inbox.pending.lock().await.remove(&id);
1616 return Err(format!("Write error: {}", e));
1617 }
1618
1619 match rx.await {
1620 Ok(payload) => payload,
1621 Err(_) => {
1622 self.inbox.pending.lock().await.remove(&id);
1627 Err("transport closed: response channel dropped".to_string())
1628 }
1629 }
1630 }
1631
1632 async fn call_no_restart(&self, method: &str, params: Value) -> Result<Value, String> {
1633 let _call_guard = self.call_lock.lock().await;
1634 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1635 let mut state_guard = self.state.lock().await;
1636 if state_guard.is_none() {
1637 *state_guard = Some(
1638 Self::spawn_state(
1639 &self.id,
1640 &self.command,
1641 &self.args,
1642 self.cwd.as_ref(),
1643 self.inbox.clone(),
1644 )
1645 .await?,
1646 );
1647 }
1648 self.call_once_locked(
1649 state_guard.as_mut().expect("state should exist"),
1650 method,
1651 params,
1652 id,
1653 )
1654 .await
1655 }
1656
1657 async fn call_no_spawn(&self, method: &str, params: Value) -> Result<Value, String> {
1661 let _call_guard = self.call_lock.lock().await;
1662 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1663 let mut state_guard = self.state.lock().await;
1664 let Some(state) = state_guard.as_mut() else {
1665 return Err(format!(
1666 "Extension '{}' process is not running; '{}' refused (no respawn)",
1667 self.id, method
1668 ));
1669 };
1670 self.call_once_locked(state, method, params, id).await
1671 }
1672
1673 pub(crate) async fn call_tool_once_no_spawn(
1679 &self,
1680 name: &str,
1681 input: Value,
1682 ) -> Result<Value, String> {
1683 let fut = async {
1684 let _call_guard = self.call_lock.lock().await;
1685 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1686 let mut state = {
1691 let mut state_guard = self.state.lock().await;
1692 state_guard.take().ok_or_else(|| {
1693 format!(
1694 "Extension '{}' process is not running; leased call refused (no respawn)",
1695 self.id
1696 )
1697 })?
1698 };
1699 let result = self
1700 .call_once_locked(
1701 &mut state,
1702 "tool.call",
1703 serde_json::json!({"name": name, "input": input}),
1704 id,
1705 )
1706 .await;
1707 if !self.inbox.closed.load(std::sync::atomic::Ordering::Acquire) {
1708 *self.state.lock().await = Some(state);
1709 } else {
1710 state.reader_handle.abort();
1711 let _ = state.child.kill().await;
1712 }
1713 result
1714 };
1715 match tokio::time::timeout(std::time::Duration::from_secs(120), fut).await {
1716 Ok(result) => result,
1717 Err(_) => Err(format!(
1718 "Extension '{}' leased tool.call timed out after 120s",
1719 self.id
1720 )),
1721 }
1722 }
1723
1724 async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
1725 let timeout_secs = if method == "tool.call" { 120 } else { 30 };
1726 let id_str = self.id.clone();
1727 let method_str = method.to_string();
1728
1729 let result = tokio::time::timeout(
1730 std::time::Duration::from_secs(timeout_secs),
1731 self.call_inner(method, params),
1732 )
1733 .await;
1734
1735 match result {
1736 Ok(inner) => inner,
1737 Err(_) => Err(format!(
1738 "Extension '{}' method '{}' timed out after {}s",
1739 id_str, method_str, timeout_secs
1740 )),
1741 }
1742 }
1743
1744 async fn call_inner(&self, method: &str, params: Value) -> Result<Value, String> {
1745 let _call_guard = self.call_lock.lock().await;
1746 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1747 let mut state_guard = self.state.lock().await;
1748 if state_guard.is_none() {
1749 self.restart_locked(&mut state_guard).await?;
1750 }
1751
1752 let result = self
1753 .call_once_locked(
1754 state_guard
1755 .as_mut()
1756 .expect("state should exist after restart"),
1757 method,
1758 params.clone(),
1759 id,
1760 )
1761 .await;
1762
1763 match result {
1764 Ok(value) => {
1765 self.restart_count.store(0, Ordering::Relaxed);
1769 Ok(value)
1770 }
1771 Err(first_error) => {
1772 self.restart_locked(&mut state_guard).await?;
1773 let retry_id = self.next_id.fetch_add(1, Ordering::Relaxed);
1774 self.call_once_locked(
1775 state_guard
1776 .as_mut()
1777 .expect("state should exist after restart"),
1778 method,
1779 params,
1780 retry_id,
1781 )
1782 .await
1783 .inspect(|_| {
1784 self.restart_count.store(0, Ordering::Relaxed);
1786 })
1787 .map_err(|retry_error| {
1788 format!(
1789 "{}; retry after restart failed: {}",
1790 first_error, retry_error
1791 )
1792 })
1793 }
1794 }
1795 }
1796
1797 #[doc(hidden)]
1815 pub async fn subscribe_notifications(&self) -> (usize, mpsc::Receiver<NotificationFrame>) {
1816 let (tx, rx) = mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
1817 let id = self
1818 .inbox
1819 .next_sink_id
1820 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1821 let mut sinks = self.inbox.notification_sinks.lock().await;
1822 sinks.retain(|(_, tx)| !tx.is_closed());
1826 sinks.push((id, tx));
1827 (id, rx)
1828 }
1829
1830 #[doc(hidden)]
1835 pub async fn unsubscribe_notifications(&self, id: usize) {
1836 let mut sinks = self.inbox.notification_sinks.lock().await;
1837 sinks.retain(|(sub_id, tx)| *sub_id != id && !tx.is_closed());
1838 }
1839
1840 pub(crate) async fn forward_invoke_command_frame(
1859 extension_id: &str,
1860 request_id: &str,
1861 sink: &crate::extensions::invoke_output::InvokeEventSink,
1862 sink_open: &mut bool,
1863 frame: NotificationFrame,
1864 ) -> bool {
1865 use crate::extensions::commands::parse_command_output;
1866 use crate::extensions::runtime::InvokeCommandEvent;
1867 use crate::extensions::tasks::{is_task_method, parse_task_event};
1868
1869 let mut saw_done = false;
1870 if frame.method == "command.output" {
1871 match parse_command_output(&frame.params) {
1872 Ok(parsed) if parsed.request_id == request_id => {
1873 if matches!(
1874 parsed.event,
1875 crate::extensions::commands::CommandOutputEvent::Done
1876 ) {
1877 saw_done = true;
1878 }
1879 if *sink_open
1880 && sink
1881 .send(InvokeCommandEvent::Output(parsed.event))
1882 .await
1883 .is_err()
1884 {
1885 *sink_open = false;
1886 }
1887 }
1888 Ok(_) => {
1889 tracing::trace!(
1891 extension = %extension_id,
1892 "Ignoring command.output for unrelated request_id",
1893 );
1894 }
1895 Err(error) => {
1896 tracing::warn!(
1897 extension = %extension_id,
1898 error = %error,
1899 params = %frame.params,
1900 "Skipping malformed command.output notification",
1901 );
1902 }
1903 }
1904 } else if is_task_method(&frame.method) {
1905 match parse_task_event(&frame.method, &frame.params) {
1906 Ok(event) => {
1907 if *sink_open && sink.send(InvokeCommandEvent::Task(event)).await.is_err() {
1908 *sink_open = false;
1909 }
1910 }
1911 Err(error) => {
1912 tracing::warn!(
1913 extension = %extension_id,
1914 method = %frame.method,
1915 error = %error,
1916 params = %frame.params,
1917 "Skipping malformed task notification",
1918 );
1919 }
1920 }
1921 } else {
1922 tracing::trace!(
1923 extension = %extension_id,
1924 method = %frame.method,
1925 "Ignoring non-command/task notification during command.invoke",
1926 );
1927 }
1928 saw_done
1929 }
1930
1931 async fn forward_provider_stream_frame(
1941 extension_id: &str,
1942 sink: &mpsc::Sender<ProviderStreamEvent>,
1943 sink_open: &mut bool,
1944 frame: NotificationFrame,
1945 ) {
1946 if frame.method != "provider.stream.event" {
1947 tracing::trace!(
1948 extension = %extension_id,
1949 method = %frame.method,
1950 "Ignoring non-stream notification during provider.stream",
1951 );
1952 return;
1953 }
1954 match parse_provider_stream_event(&frame.params) {
1955 Ok(event) => {
1956 if *sink_open && sink.send(event).await.is_err() {
1957 *sink_open = false;
1958 }
1959 }
1960 Err(error) => {
1961 tracing::warn!(
1962 extension = %extension_id,
1963 error = %error,
1964 params = %frame.params,
1965 "Skipping malformed provider.stream.event notification",
1966 );
1967 }
1968 }
1969 }
1970}
1971
1972#[async_trait]
1973impl ExtensionHandler for ProcessExtension {
1974 fn id(&self) -> &str {
1975 &self.id
1976 }
1977
1978 async fn call_tool(&self, name: &str, input: Value) -> Result<Value, String> {
1979 self.call(
1980 "tool.call",
1981 serde_json::json!({
1982 "name": name,
1983 "input": input,
1984 }),
1985 )
1986 .await
1987 }
1988
1989 async fn provider_complete(
1990 &self,
1991 params: ProviderCompleteParams,
1992 ) -> Result<ProviderCompleteResult, String> {
1993 let value = tokio::time::timeout(
1994 std::time::Duration::from_secs(60),
1995 self.call(
1996 "provider.complete",
1997 serde_json::to_value(params).map_err(|e| e.to_string())?,
1998 ),
1999 )
2000 .await
2001 .map_err(|_| format!("Extension '{}' provider.complete timed out", self.id))??;
2002 let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
2003 format!(
2004 "Invalid provider.complete response from extension '{}': {}",
2005 self.id, e
2006 )
2007 })?;
2008 if result.content.is_empty() {
2009 return Err(format!(
2010 "Extension '{}' provider.complete returned empty content",
2011 self.id
2012 ));
2013 }
2014 Ok(result)
2015 }
2016
2017 async fn provider_stream(
2018 &self,
2019 params: ProviderCompleteParams,
2020 sink: tokio::sync::mpsc::Sender<ProviderStreamEvent>,
2021 ) -> Result<ProviderCompleteResult, String> {
2022 let (sub_id, mut rx) = self.subscribe_notifications().await;
2025 let params_value = serde_json::to_value(params).map_err(|e| e.to_string())?;
2026
2027 let extension_id = self.id.clone();
2028 let stream_future = async {
2029 let mut call_fut = Box::pin(self.call("provider.stream", params_value));
2030 let mut sink_open = true;
2031 let response = loop {
2032 tokio::select! {
2033 response = &mut call_fut => break response,
2034 Some(frame) = rx.recv() => {
2035 Self::forward_provider_stream_frame(
2036 &extension_id, &sink, &mut sink_open, frame,
2037 ).await;
2038 }
2039 }
2040 };
2041 self.unsubscribe_notifications(sub_id).await;
2046 while let Some(frame) = rx.recv().await {
2047 Self::forward_provider_stream_frame(&extension_id, &sink, &mut sink_open, frame)
2048 .await;
2049 }
2050 response
2051 };
2052
2053 let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), stream_future).await;
2054
2055 self.unsubscribe_notifications(sub_id).await;
2058
2059 let value =
2060 outcome.map_err(|_| format!("Extension '{}' provider.stream timed out", self.id))??;
2061
2062 let result: ProviderCompleteResult = serde_json::from_value(value).map_err(|e| {
2063 format!(
2064 "Invalid provider.stream response from extension '{}': {}",
2065 self.id, e
2066 )
2067 })?;
2068 Ok(result)
2071 }
2072
2073 async fn invoke_command(
2074 &self,
2075 command: &str,
2076 args: Vec<String>,
2077 request_id: &str,
2078 sink: crate::extensions::invoke_output::InvokeEventSink,
2079 ) -> Result<Value, String> {
2080 let (sub_id, mut rx) = self.subscribe_notifications().await;
2082 let params = serde_json::json!({
2083 "command": command,
2084 "args": args,
2085 "request_id": request_id,
2086 });
2087
2088 let extension_id = self.id.clone();
2089 let request_id_owned = request_id.to_string();
2090 let invoke_future = async {
2091 let mut call_fut = Box::pin(self.call("command.invoke", params));
2092 let mut sink_open = true;
2093 let response = loop {
2094 tokio::select! {
2095 response = &mut call_fut => break response,
2096 Some(frame) = rx.recv() => {
2097 let _ = Self::forward_invoke_command_frame(
2103 &extension_id, &request_id_owned, &sink, &mut sink_open, frame,
2104 ).await;
2105 }
2106 }
2107 };
2108 self.unsubscribe_notifications(sub_id).await;
2112 while let Ok(frame) = rx.try_recv() {
2113 let _ = Self::forward_invoke_command_frame(
2114 &extension_id,
2115 &request_id_owned,
2116 &sink,
2117 &mut sink_open,
2118 frame,
2119 )
2120 .await;
2121 }
2122 response
2123 };
2124
2125 let outcome =
2126 tokio::time::timeout(std::time::Duration::from_secs(120), invoke_future).await;
2127
2128 self.unsubscribe_notifications(sub_id).await;
2133
2134 outcome.map_err(|_| format!("Extension '{}' command.invoke timed out", self.id))?
2135 }
2136
2137 async fn handle(&self, event: &HookEvent) -> HookResult {
2138 let params = serde_json::to_value(event).unwrap_or(Value::Null);
2139 match tokio::time::timeout(
2140 std::time::Duration::from_secs(5),
2141 self.call("hook.handle", params),
2142 )
2143 .await
2144 {
2145 Ok(Ok(value)) => match serde_json::from_value(value.clone()) {
2146 Ok(result) => result,
2147 Err(error) => {
2148 tracing::warn!(
2149 extension = %self.id,
2150 error = %error,
2151 response = %value,
2152 "Extension hook handler returned invalid result",
2153 );
2154 match value.get("action").and_then(Value::as_str) {
2155 Some("modify") => HookResult::Block {
2158 reason: "Extension returned malformed modify result".to_string(),
2159 },
2160 Some("replace") => HookResult::Continue,
2165 _ => HookResult::Continue,
2166 }
2167 }
2168 },
2169 Ok(Err(e)) => {
2170 tracing::warn!(
2171 extension = %self.id,
2172 error = %e,
2173 "Extension hook handler failed — continuing",
2174 );
2175 HookResult::Continue
2176 }
2177 Err(_) => {
2178 tracing::warn!(
2179 extension = %self.id,
2180 timeout_secs = 5,
2181 "Extension hook handler timed out — continuing",
2182 );
2183 HookResult::Continue
2184 }
2185 }
2186 }
2187
2188 async fn get_info(&self) -> Result<crate::extensions::info::PluginInfo, String> {
2189 let value = tokio::time::timeout(
2190 std::time::Duration::from_secs(5),
2191 self.call("info.get", Value::Null),
2192 )
2193 .await
2194 .map_err(|_| format!("Extension '{}' info.get timed out", self.id))??;
2195 serde_json::from_value(value).map_err(|e| {
2196 format!(
2197 "Invalid info.get response from extension '{}': {}",
2198 self.id, e
2199 )
2200 })
2201 }
2202
2203 async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
2204 let value = tokio::time::timeout(
2205 std::time::Duration::from_secs(5),
2206 self.call("sidecar.spawn_args", Value::Null),
2207 )
2208 .await
2209 .map_err(|_| format!("Extension '{}' sidecar.spawn_args timed out", self.id))??;
2210 serde_json::from_value(value).map_err(|e| {
2211 format!(
2212 "Invalid sidecar.spawn_args response from extension '{}': {}",
2213 self.id, e
2214 )
2215 })
2216 }
2217
2218 async fn settings_editor_open(&self, category: &str, field: &str) -> Result<Value, String> {
2219 let params = crate::extensions::settings_editor::SettingsEditorOpenParams {
2220 category: category.to_string(),
2221 field: field.to_string(),
2222 };
2223 tokio::time::timeout(
2224 std::time::Duration::from_secs(5),
2225 self.call(
2226 "settings.editor.open",
2227 serde_json::to_value(params).map_err(|e| e.to_string())?,
2228 ),
2229 )
2230 .await
2231 .map_err(|_| format!("Extension '{}' settings.editor.open timed out", self.id))?
2232 }
2233
2234 async fn settings_editor_key(
2235 &self,
2236 category: &str,
2237 field: &str,
2238 key: &str,
2239 ) -> Result<Value, String> {
2240 let mut params = serde_json::to_value(
2241 crate::extensions::settings_editor::SettingsEditorKeyParams {
2242 key: key.to_string(),
2243 },
2244 )
2245 .map_err(|e| e.to_string())?;
2246 if let Some(obj) = params.as_object_mut() {
2247 obj.insert("category".to_string(), Value::String(category.to_string()));
2248 obj.insert("field".to_string(), Value::String(field.to_string()));
2249 }
2250 tokio::time::timeout(
2251 std::time::Duration::from_secs(5),
2252 self.call("settings.editor.key", params),
2253 )
2254 .await
2255 .map_err(|_| format!("Extension '{}' settings.editor.key timed out", self.id))?
2256 }
2257
2258 async fn settings_editor_commit(
2259 &self,
2260 category: &str,
2261 field: &str,
2262 value: Value,
2263 ) -> Result<Value, String> {
2264 let params = serde_json::json!({
2265 "category": category,
2266 "field": field,
2267 "value": value,
2268 });
2269 tokio::time::timeout(
2270 std::time::Duration::from_secs(5),
2271 self.call("settings.editor.commit", params),
2272 )
2273 .await
2274 .map_err(|_| format!("Extension '{}' settings.editor.commit timed out", self.id))?
2275 }
2276
2277 async fn shutdown(&self) {
2278 let _ = tokio::time::timeout(
2284 std::time::Duration::from_millis(500),
2285 self.call_no_spawn("shutdown", Value::Null),
2286 )
2287 .await;
2288
2289 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2290 let mut state_guard = self.state.lock().await;
2291 if let Some(state) = state_guard.take() {
2292 state.reader_handle.abort();
2293 let mut child = state.child;
2294 let _ = child.kill().await;
2295 }
2296 self.inbox.notification_sinks.lock().await.clear();
2298 self.inbox
2299 .fail_all_pending("transport closed: extension shutdown")
2300 .await;
2301 }
2302
2303 async fn subscribe_notifications(
2304 &self,
2305 ) -> (usize, tokio::sync::mpsc::Receiver<NotificationFrame>) {
2306 ProcessExtension::subscribe_notifications(self).await
2307 }
2308
2309 async fn restart_count(&self) -> usize {
2310 self.restart_count()
2311 }
2312
2313 async fn health(&self) -> ExtensionHealth {
2314 let consecutive = self.restart_count.load(Ordering::Relaxed);
2318 let lifetime = self.total_restarts.load(Ordering::Relaxed);
2319 let max = self.restart_policy.max_attempts as usize;
2320 if consecutive >= max {
2321 ExtensionHealth::Failed
2322 } else if lifetime > 0 {
2323 let state_alive = self.state.try_lock().map(|g| g.is_some()).unwrap_or(true);
2327 if state_alive {
2328 ExtensionHealth::Degraded
2329 } else {
2330 ExtensionHealth::Restarting
2331 }
2332 } else {
2333 ExtensionHealth::Running
2334 }
2335 }
2336}
2337
2338#[cfg(test)]
2339mod stream_event_tests {
2340 use super::*;
2341 use serde_json::json;
2342
2343 #[test]
2344 fn parses_text_delta_with_delta_key() {
2345 let v = json!({"type": "text", "delta": "hi"});
2346 assert_eq!(
2347 parse_provider_stream_event(&v).unwrap(),
2348 ProviderStreamEvent::TextDelta { text: "hi".into() }
2349 );
2350 }
2351
2352 #[test]
2353 fn parses_text_delta_with_text_key() {
2354 let v = json!({"type": "text", "text": "hi"});
2355 assert_eq!(
2356 parse_provider_stream_event(&v).unwrap(),
2357 ProviderStreamEvent::TextDelta { text: "hi".into() }
2358 );
2359 }
2360
2361 #[test]
2362 fn parses_thinking_delta() {
2363 let v = json!({"type": "thinking", "delta": "hmm"});
2364 assert_eq!(
2365 parse_provider_stream_event(&v).unwrap(),
2366 ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2367 );
2368 let v2 = json!({"type": "thinking", "text": "hmm"});
2369 assert_eq!(
2370 parse_provider_stream_event(&v2).unwrap(),
2371 ProviderStreamEvent::ThinkingDelta { text: "hmm".into() }
2372 );
2373 }
2374
2375 #[test]
2376 fn parses_tool_use() {
2377 let v = json!({
2378 "type": "tool_use",
2379 "id": "t1",
2380 "name": "echo",
2381 "input": {"x": 1}
2382 });
2383 assert_eq!(
2384 parse_provider_stream_event(&v).unwrap(),
2385 ProviderStreamEvent::ToolUse {
2386 id: "t1".into(),
2387 name: "echo".into(),
2388 input: json!({"x": 1}),
2389 }
2390 );
2391 }
2392
2393 #[test]
2394 fn tool_use_input_defaults_to_empty_object() {
2395 let v = json!({"type": "tool_use", "id": "t1", "name": "echo"});
2396 assert_eq!(
2397 parse_provider_stream_event(&v).unwrap(),
2398 ProviderStreamEvent::ToolUse {
2399 id: "t1".into(),
2400 name: "echo".into(),
2401 input: json!({}),
2402 }
2403 );
2404 }
2405
2406 #[test]
2407 fn parses_usage_strips_type() {
2408 let v = json!({"type": "usage", "input_tokens": 5, "output_tokens": 7});
2409 assert_eq!(
2410 parse_provider_stream_event(&v).unwrap(),
2411 ProviderStreamEvent::Usage {
2412 usage: json!({"input_tokens": 5, "output_tokens": 7})
2413 }
2414 );
2415 }
2416
2417 #[test]
2418 fn parses_error() {
2419 let v = json!({"type": "error", "message": "boom"});
2420 assert_eq!(
2421 parse_provider_stream_event(&v).unwrap(),
2422 ProviderStreamEvent::Error {
2423 message: "boom".into()
2424 }
2425 );
2426 }
2427
2428 #[test]
2429 fn parses_done() {
2430 let v = json!({"type": "done"});
2431 assert_eq!(
2432 parse_provider_stream_event(&v).unwrap(),
2433 ProviderStreamEvent::Done
2434 );
2435 }
2436
2437 #[test]
2438 fn nested_event_shape_matches_flat() {
2439 let flat = json!({"type": "text", "delta": "hi"});
2440 let nested = json!({"event": {"type": "text", "delta": "hi"}});
2441 assert_eq!(
2442 parse_provider_stream_event(&flat).unwrap(),
2443 parse_provider_stream_event(&nested).unwrap()
2444 );
2445 }
2446
2447 #[test]
2448 fn missing_type_errors() {
2449 let v = json!({"delta": "hi"});
2450 let err = parse_provider_stream_event(&v).unwrap_err();
2451 assert!(err.contains("missing type"), "got: {err}");
2452 }
2453
2454 #[test]
2455 fn unknown_type_errors_with_type() {
2456 let v = json!({"type": "wat"});
2457 let err = parse_provider_stream_event(&v).unwrap_err();
2458 assert!(err.contains("wat"), "got: {err}");
2459 }
2460
2461 #[test]
2462 fn tool_use_missing_id_errors() {
2463 let v = json!({"type": "tool_use", "name": "echo"});
2464 let err = parse_provider_stream_event(&v).unwrap_err();
2465 assert!(err.contains("id"), "got: {err}");
2466 }
2467
2468 #[test]
2469 fn tool_use_missing_name_errors() {
2470 let v = json!({"type": "tool_use", "id": "t1"});
2471 let err = parse_provider_stream_event(&v).unwrap_err();
2472 assert!(err.contains("name"), "got: {err}");
2473 }
2474
2475 #[test]
2476 fn tool_use_empty_id_errors() {
2477 let v = json!({"type": "tool_use", "id": "", "name": "echo"});
2478 assert!(parse_provider_stream_event(&v).is_err());
2479 }
2480
2481 #[test]
2482 fn tool_use_empty_name_errors() {
2483 let v = json!({"type": "tool_use", "id": "t1", "name": ""});
2484 assert!(parse_provider_stream_event(&v).is_err());
2485 }
2486
2487 #[test]
2488 fn tool_use_non_object_input_errors() {
2489 let v = json!({"type": "tool_use", "id": "t1", "name": "echo", "input": "nope"});
2490 let err = parse_provider_stream_event(&v).unwrap_err();
2491 assert!(err.contains("input"), "got: {err}");
2492 }
2493
2494 #[test]
2495 fn text_missing_delta_and_text_errors() {
2496 let v = json!({"type": "text"});
2497 let err = parse_provider_stream_event(&v).unwrap_err();
2498 assert!(err.contains("delta") || err.contains("text"), "got: {err}");
2499 }
2500
2501 #[test]
2502 fn error_missing_message_errors() {
2503 let v = json!({"type": "error"});
2504 assert!(parse_provider_stream_event(&v).is_err());
2505 }
2506
2507 #[test]
2508 fn error_empty_message_errors() {
2509 let v = json!({"type": "error", "message": ""});
2510 assert!(parse_provider_stream_event(&v).is_err());
2511 }
2512}
2513
2514#[cfg(test)]
2515mod restart_policy_tests {
2516 use super::*;
2517
2518 #[tokio::test]
2519 async fn restart_policy_default_max_attempts_is_3() {
2520 let ext = ProcessExtension::spawn("policy-test", "/bin/cat", &[])
2525 .await
2526 .expect("spawn /bin/cat");
2527 assert_eq!(ext.restart_policy.max_attempts, 3);
2528 ext.shutdown().await;
2529 }
2530
2531 #[tokio::test]
2532 async fn with_restart_policy_overrides_default() {
2533 let ext = ProcessExtension::spawn("policy-test-override", "/bin/cat", &[])
2534 .await
2535 .expect("spawn /bin/cat");
2536 let custom = RestartPolicy {
2537 max_attempts: 7,
2538 ..RestartPolicy::default()
2539 };
2540 let ext = ext.with_restart_policy(custom);
2541 assert_eq!(ext.restart_policy.max_attempts, 7);
2542 ext.shutdown().await;
2543 }
2544}
2545
2546#[cfg(test)]
2547mod capture_validator_tests {
2548 use super::*;
2549 use crate::extensions::permissions::{Permission, PermissionSet};
2550
2551 fn perms_with(grants: &[Permission]) -> PermissionSet {
2552 let mut p = PermissionSet::new();
2553 for g in grants {
2554 p.grant(*g);
2555 }
2556 p
2557 }
2558
2559 fn cap(kind: &str, name: &str, perms: &[&str]) -> CapabilityDeclaration {
2560 CapabilityDeclaration {
2561 kind: kind.to_string(),
2562 name: name.to_string(),
2563 permissions: perms.iter().map(|p| p.to_string()).collect(),
2564 params: serde_json::Value::Null,
2565 }
2566 }
2567
2568 #[test]
2569 fn capability_validator_rejects_empty_kind() {
2570 let d = cap(" ", "Sample", &["audio.input"]);
2571 let perms = perms_with(&[Permission::AudioInput]);
2572 let err = validate_capability(&d, &perms).unwrap_err();
2573 assert!(err.contains("kind"), "got: {}", err);
2574 }
2575
2576 #[test]
2577 fn capability_validator_rejects_empty_name() {
2578 let d = cap("capture", " ", &["audio.input"]);
2579 let perms = perms_with(&[Permission::AudioInput]);
2580 let err = validate_capability(&d, &perms).unwrap_err();
2581 assert!(err.contains("name"), "got: {}", err);
2582 }
2583
2584 #[test]
2585 fn capability_validator_rejects_unknown_permission_string() {
2586 let d = cap("capture", "Sample", &["audio.telepathy"]);
2587 let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2588 let err = validate_capability(&d, &perms).unwrap_err();
2589 assert!(
2590 err.contains("unknown permission") && err.contains("audio.telepathy"),
2591 "got: {}",
2592 err,
2593 );
2594 }
2595
2596 #[test]
2597 fn capability_validator_requires_every_declared_permission() {
2598 let d = cap("capture", "Sample", &["audio.input"]);
2599 let perms = perms_with(&[]);
2600 let err = validate_capability(&d, &perms).unwrap_err();
2601 assert!(
2602 err.contains("audio.input") && err.contains("not granted"),
2603 "got: {}",
2604 err,
2605 );
2606 }
2607
2608 #[test]
2609 fn capability_validator_accepts_when_all_permissions_granted() {
2610 let d = cap("capture", "Sample", &["audio.input", "audio.output"]);
2611 let perms = perms_with(&[Permission::AudioInput, Permission::AudioOutput]);
2612 validate_capability(&d, &perms).expect("should validate");
2613 }
2614
2615 #[test]
2616 fn capability_validator_accepts_no_permissions() {
2617 let d = cap("ocr", "Tesseract", &[]);
2621 let perms = perms_with(&[]);
2622 validate_capability(&d, &perms).expect("should validate");
2623 }
2624
2625 #[test]
2626 fn capability_validator_does_not_branch_on_kind() {
2627 let perms = perms_with(&[Permission::AudioInput]);
2631 for kind in ["capture", "ocr", "agent", "foot_pedal", "eeg"] {
2632 let d = cap(kind, "Anything", &["audio.input"]);
2633 validate_capability(&d, &perms).expect("should validate");
2634 }
2635 }
2636}
2637
2638#[cfg(test)]
2639mod invoke_command_dispatch_tests {
2640 use super::*;
2645 use crate::extensions::commands::CommandOutputEvent;
2646 use crate::extensions::invoke_output::{invoke_event_channel, InvokeOutputBudget};
2647 use crate::extensions::runtime::InvokeCommandEvent;
2648 use crate::extensions::tasks::{TaskEvent, TaskKind};
2649 use serde_json::json;
2650
2651 fn frame(method: &str, params: serde_json::Value) -> NotificationFrame {
2652 NotificationFrame {
2653 method: method.to_string(),
2654 params,
2655 }
2656 }
2657
2658 async fn dispatch(frames: Vec<NotificationFrame>) -> (Vec<InvokeCommandEvent>, bool) {
2663 let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
2664 let mut open = true;
2665 let mut saw_done = false;
2666 for f in frames {
2667 saw_done |=
2668 ProcessExtension::forward_invoke_command_frame("ext-test", "r1", &tx, &mut open, f)
2669 .await;
2670 }
2671 drop(tx);
2672 let report = collector.collect().await;
2673 (report.events, saw_done)
2674 }
2675
2676 #[tokio::test]
2677 async fn forwards_mixed_event_stream_in_order() {
2678 let frames = vec![
2679 frame(
2680 "command.output",
2681 json!({"request_id":"r1","event":{"kind":"text","content":"A"}}),
2682 ),
2683 frame(
2684 "task.start",
2685 json!({"id":"dl","label":"Downloading","kind":"download"}),
2686 ),
2687 frame("task.update", json!({"id":"dl","current":50,"total":100})),
2688 frame(
2689 "command.output",
2690 json!({"request_id":"r1","event":{"kind":"system","content":"working"}}),
2691 ),
2692 frame("task.done", json!({"id":"dl"})),
2693 frame(
2694 "command.output",
2695 json!({"request_id":"r1","event":{"kind":"done"}}),
2696 ),
2697 ];
2698
2699 let (events, saw_done) = dispatch(frames).await;
2700 assert!(saw_done, "should have observed the command Done marker");
2701 assert_eq!(events.len(), 6);
2702 assert_eq!(
2703 events[0],
2704 InvokeCommandEvent::Output(CommandOutputEvent::Text {
2705 content: "A".into()
2706 })
2707 );
2708 assert!(matches!(
2709 events[1],
2710 InvokeCommandEvent::Task(TaskEvent::Start {
2711 kind: TaskKind::Download,
2712 ..
2713 })
2714 ));
2715 assert!(matches!(
2716 events[2],
2717 InvokeCommandEvent::Task(TaskEvent::Update { .. })
2718 ));
2719 assert!(matches!(
2720 events[3],
2721 InvokeCommandEvent::Output(CommandOutputEvent::System { .. })
2722 ));
2723 assert!(matches!(
2724 events[4],
2725 InvokeCommandEvent::Task(TaskEvent::Done { error: None, .. })
2726 ));
2727 assert_eq!(
2728 events[5],
2729 InvokeCommandEvent::Output(CommandOutputEvent::Done)
2730 );
2731 }
2732
2733 #[tokio::test]
2734 async fn ignores_command_output_for_unrelated_request_id() {
2735 let (events, saw_done) = dispatch(vec![frame(
2736 "command.output",
2737 json!({"request_id":"other","event":{"kind":"text","content":"x"}}),
2738 )])
2739 .await;
2740 assert!(!saw_done);
2741 assert!(events.is_empty());
2742 }
2743
2744 #[tokio::test]
2745 async fn skips_malformed_command_output_without_aborting() {
2746 let (events, saw_done) = dispatch(vec![
2747 frame("command.output", json!({"request_id":"r1","event":{}})),
2749 frame(
2751 "command.output",
2752 json!({"request_id":"r1","event":{"kind":"done"}}),
2753 ),
2754 ])
2755 .await;
2756 assert!(saw_done);
2757 assert_eq!(
2758 events,
2759 vec![InvokeCommandEvent::Output(CommandOutputEvent::Done)]
2760 );
2761 }
2762
2763 #[tokio::test]
2764 async fn task_events_pass_through_regardless_of_request_id() {
2765 let (events, _) = dispatch(vec![frame("task.log", json!({"id":"abc","line":"..."}))]).await;
2766 match &events[..] {
2767 [InvokeCommandEvent::Task(TaskEvent::Log { id, line })] => {
2768 assert_eq!(id, "abc");
2769 assert_eq!(line, "...");
2770 }
2771 other => panic!("unexpected: {other:?}"),
2772 }
2773 }
2774
2775 #[tokio::test]
2776 async fn unrelated_methods_are_dropped() {
2777 let (events, saw_done) = dispatch(vec![frame(
2778 "provider.stream.event",
2779 json!({"type":"text","delta":"x"}),
2780 )])
2781 .await;
2782 assert!(!saw_done);
2783 assert!(events.is_empty());
2784 }
2785
2786 #[tokio::test]
2789 async fn closed_sink_latches_open_false_without_blocking() {
2790 let (tx, collector) = invoke_event_channel(InvokeOutputBudget::default());
2791 drop(collector);
2792 let mut open = true;
2793 let saw_done = ProcessExtension::forward_invoke_command_frame(
2794 "ext-test",
2795 "r1",
2796 &tx,
2797 &mut open,
2798 frame(
2799 "command.output",
2800 json!({"request_id":"r1","event":{"kind":"done"}}),
2801 ),
2802 )
2803 .await;
2804 assert!(saw_done);
2806 assert!(!open);
2807 }
2808}