Skip to main content

zeph_core/agent/
acp_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt::Write as _;
5use std::future::Future;
6use std::pin::Pin;
7
8use tracing::Instrument as _;
9use zeph_commands::{CommandError, IntegrationAccess};
10
11#[cfg(feature = "cocoon")]
12use super::command_macros::delegate_cmd;
13use super::{Agent, error::AgentError};
14use crate::channel::Channel;
15
16/// Run Stage-2 LLM semantic scan for all skills in the plugin at `source`.
17///
18/// Skills are scanned concurrently with up to 4 in-flight at a time
19/// (`buffer_unordered(4)`). An aggregate 5-minute timeout wraps the whole
20/// batch; each individual scan is already bounded by `SCAN_TIMEOUT` (30 s) in
21/// `SkillSemanticScanner`. Returns `Some(err_msg)` when any skill is blocked,
22/// `None` when all skills pass.
23///
24/// Each future carries its own `skill_name` so that the rejection message names
25/// the correct skill regardless of completion order (which differs from input
26/// order when futures complete out-of-order with `buffer_unordered`).
27async fn semantic_scan_plugin_add(
28    scanner: &zeph_skills::semantic_scanner::SkillSemanticScanner,
29    source: &str,
30    managed_dir: Option<std::path::PathBuf>,
31    mcp_allowed: Vec<String>,
32    base_shell_allowed: Vec<String>,
33) -> Result<Option<String>, CommandError> {
34    use futures::stream::StreamExt as _;
35    use zeph_skills::semantic_scanner::ScanVerdict;
36
37    let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
38    let mgr_dir =
39        managed_dir.unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
40    let mgr =
41        zeph_plugins::PluginManager::new(plugins_dir, mgr_dir, mcp_allowed, base_shell_allowed);
42
43    let source_owned = source.to_owned();
44    let scan_inputs = tokio::task::spawn_blocking(move || mgr.scan_targets(&source_owned))
45        .await
46        .map_err(|e| CommandError(format!("plugin scan_targets panicked: {e}")))?
47        .map_err(|e| CommandError(format!("plugin add failed: {e}")))?;
48
49    tracing::info!(
50        plugin.source = %source,
51        skills_count = scan_inputs.len(),
52        "plugins.add: running Stage-2 semantic scan"
53    );
54
55    // Scan all skills concurrently with up to 4 in-flight. Each individual scan is
56    // already bounded by SCAN_TIMEOUT (30 s); the outer 5-min cap guards the batch.
57    // Each future owns its skill_name so verdicts carry the correct name regardless
58    // of buffer_unordered completion order (which is not the same as input order).
59    let scan_futs: Vec<_> = scan_inputs
60        .iter()
61        .map(|input| {
62            let name = input.skill_name.clone();
63            let purpose = input.declared_purpose.clone();
64            let md = input.skill_md.clone();
65            async move {
66                let verdict = scanner.scan(&name, &purpose, &md).await;
67                (name, verdict)
68            }
69        })
70        .collect();
71
72    let verdicts: Vec<_> = tokio::time::timeout(
73        std::time::Duration::from_mins(5),
74        futures::stream::iter(scan_futs)
75            .buffer_unordered(4)
76            .collect::<Vec<_>>(),
77    )
78    .await
79    .map_err(|_| CommandError("plugin scan timed out after 300s".to_owned()))?;
80
81    for (skill_name, verdict_result) in verdicts {
82        let verdict = verdict_result.map_err(|e| {
83            CommandError(format!(
84                "plugin add failed: semantic scan error for skill {skill_name:?}: {e}"
85            ))
86        })?;
87        match verdict {
88            ScanVerdict::Allow => {
89                tracing::debug!(
90                    skill = %skill_name,
91                    "plugins.add: skill passed semantic scan"
92                );
93            }
94            ScanVerdict::Warn(ref reason) => {
95                tracing::warn!(
96                    skill = %skill_name,
97                    reason = %reason,
98                    "plugins.add: skill passed with warning"
99                );
100            }
101            ScanVerdict::Block(reason) => {
102                return Ok(Some(format!(
103                    "plugin add failed: skill {skill_name:?} rejected by semantic scan: {reason}"
104                )));
105            }
106            _ => {}
107        }
108    }
109    Ok(None)
110}
111
112/// Format the `additional_directories` allowlist for display.
113pub(super) fn format_acp_dirs(cfg: &zeph_config::AcpConfig) -> String {
114    let mut out = String::new();
115    let _ = writeln!(out, "ACP additional_directories allowlist:");
116    if cfg.additional_directories.is_empty() {
117        let _ = writeln!(out, "  (none configured)");
118    } else {
119        for dir in &cfg.additional_directories {
120            let _ = writeln!(out, "  {dir}");
121        }
122    }
123    out.trim_end().to_owned()
124}
125
126/// Format the `auth_methods` list for display.
127pub(super) fn format_acp_auth_methods(cfg: &zeph_config::AcpConfig) -> String {
128    let mut out = String::new();
129    let _ = writeln!(out, "ACP auth_methods:");
130    if cfg.auth_methods.is_empty() {
131        let _ = writeln!(out, "  (none configured)");
132    } else {
133        for method in &cfg.auth_methods {
134            let _ = writeln!(out, "  {method}");
135        }
136    }
137    out.trim_end().to_owned()
138}
139
140/// Format the ACP server status summary.
141pub(super) fn format_acp_status(cfg: &zeph_config::AcpConfig, is_acp_session: bool) -> String {
142    let mut out = String::new();
143    let enabled = if cfg.enabled { "enabled" } else { "disabled" };
144    let _ = writeln!(out, "ACP: {enabled}");
145    let _ = writeln!(out, "transport:       {:?}", cfg.transport);
146    let _ = writeln!(out, "agent_name:      {}", cfg.agent_name);
147    let _ = writeln!(out, "agent_version:   {}", cfg.agent_version);
148    let _ = writeln!(out, "max_sessions:    {}", cfg.max_sessions);
149    let _ = writeln!(out, "http_bind:       {}", cfg.http_bind);
150    let _ = writeln!(out, "discovery:       {}", cfg.discovery_enabled);
151    let _ = writeln!(out, "message_ids:     {}", cfg.message_ids_enabled);
152    let _ = writeln!(
153        out,
154        "this session:    {}",
155        if is_acp_session {
156            "ACP client"
157        } else {
158            "non-ACP"
159        }
160    );
161    out.trim_end().to_owned()
162}
163
164/// Pure dispatcher — separated from `Agent` for unit testing.
165pub(super) fn dispatch_acp(
166    cfg: &zeph_config::AcpConfig,
167    is_acp_session: bool,
168    args: &str,
169) -> Result<String, AgentError> {
170    match args.trim() {
171        "dirs" => Ok(format_acp_dirs(cfg)),
172        "auth-methods" => Ok(format_acp_auth_methods(cfg)),
173        "status" => Ok(format_acp_status(cfg, is_acp_session)),
174        "" => Ok(
175            "Usage: /acp <subcommand>\n\nSubcommands:\n  dirs          List additional_directories allowlist\n  auth-methods  List advertised auth methods\n  status        Show ACP server configuration summary"
176                .to_owned(),
177        ),
178        other => Err(AgentError::UnknownCommand(format!(
179            "Unknown /acp subcommand: {other}. Valid subcommands: dirs, auth-methods, status"
180        ))),
181    }
182}
183
184impl<C: Channel> Agent<C> {
185    /// Dispatch `/acp [dirs|auth-methods|status]` and return a display string.
186    pub(super) fn handle_acp_as_string(&mut self, args: &str) -> Result<String, AgentError> {
187        dispatch_acp(
188            &self.runtime.config.acp_config,
189            self.services.security.is_acp_session,
190            args,
191        )
192    }
193}
194
195impl<C: Channel + Send + 'static> IntegrationAccess for Agent<C> {
196    // ----- /plugins -----
197
198    fn handle_plugins<'a>(
199        &'a mut self,
200        args: &'a str,
201    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
202        let args_owned = args.to_owned();
203        // Clone the fields needed by PluginManager before entering the async block.
204        // spawn_blocking requires 'static, so we cannot borrow &self inside the closure.
205        let managed_dir = self.services.skill.managed_dir.clone();
206        let mcp_allowed = self.services.mcp.allowed_commands.clone();
207        let base_shell_allowed = self.runtime.lifecycle.startup_shell_overlay.allowed.clone();
208        // Same reputation config the CLI/bootstrap paths use (spec-043, #5864) — threaded
209        // through so `/plugins add` gets the identical typosquat check `zeph plugin add` does.
210        let reputation_cfg = self.runtime.config.plugins_reputation.clone();
211        // Collect manifest paths for ephemeral plugins. Reading the actual files is
212        // deferred into the async block below to avoid blocking the tokio worker thread.
213        let ephemeral_manifest_paths: Vec<std::path::PathBuf> = self
214            .runtime
215            .ephemeral_plugins
216            .iter()
217            .map(|tmp| tmp.path().join("plugin.toml"))
218            .collect();
219
220        // Resolve scanner once, before the async block captures `self`.
221        // Fail-closed: if semantic_scan is enabled but no provider is configured, refuse
222        // to proceed rather than silently falling back to the primary provider (#4706, #4709).
223        let semantic_scan_enabled = self.services.skill.semantic_scan;
224        let maybe_scanner: Option<zeph_skills::semantic_scanner::SkillSemanticScanner> =
225            if semantic_scan_enabled {
226                let provider_name = self.services.skill.semantic_scan_provider.as_str();
227                if provider_name.trim().is_empty() {
228                    return Box::pin(async move {
229                        Err(CommandError::new(
230                            "semantic_scan is enabled but semantic_scan_provider is not set; \
231                             refusing plugin add to maintain fail-closed security posture",
232                        ))
233                    });
234                }
235                let provider_known = self
236                    .runtime
237                    .providers
238                    .provider_pool
239                    .iter()
240                    .any(|e| e.effective_name().eq_ignore_ascii_case(provider_name));
241                if !provider_known {
242                    let name = provider_name.to_owned();
243                    return Box::pin(async move {
244                        Err(CommandError::new(format!(
245                            "semantic_scan is enabled but semantic_scan_provider '{name}' \
246                             is not configured in [[llm.providers]]; \
247                             refusing plugin add to maintain fail-closed security posture",
248                        )))
249                    });
250                }
251                let provider = self.resolve_background_provider(provider_name);
252                Some(zeph_skills::semantic_scanner::SkillSemanticScanner::new(
253                    provider,
254                ))
255            } else {
256                None
257            };
258
259        Box::pin(async move {
260            let (subcmd, source) = args_owned
261                .trim()
262                .split_once(' ')
263                .unwrap_or((args_owned.trim(), ""));
264
265            // Stage-2 LLM semantic scan runs before the blocking add(), fail-closed.
266            if subcmd == "add"
267                && !source.trim().is_empty()
268                && let Some(ref scanner) = maybe_scanner
269                && let Some(err) = semantic_scan_plugin_add(
270                    scanner,
271                    source.trim(),
272                    managed_dir.clone(),
273                    mcp_allowed.clone(),
274                    base_shell_allowed.clone(),
275                )
276                .instrument(tracing::info_span!("core.agent.scan_plugin", plugin = %source.trim()))
277                .await?
278            {
279                return Ok(err);
280            }
281
282            // Resolve ephemeral plugin names asynchronously before entering the blocking task.
283            let ephemeral_names: Vec<String> = {
284                use futures::future::join_all;
285                let futs = ephemeral_manifest_paths.into_iter().map(|p| async move {
286                    tokio::fs::read_to_string(&p)
287                        .await
288                        .ok()
289                        .and_then(|s| toml::from_str::<zeph_plugins::PluginManifest>(&s).ok())
290                        .map(|m| m.plugin.name.to_string())
291                });
292                join_all(futs).await.into_iter().flatten().collect()
293            };
294
295            // PluginManager performs synchronous filesystem I/O (copy, remove_dir_all,
296            // read_dir). Run on a blocking thread to avoid stalling the tokio worker.
297            tokio::task::spawn_blocking(move || {
298                Self::run_plugin_command(
299                    &args_owned,
300                    managed_dir,
301                    mcp_allowed,
302                    base_shell_allowed,
303                    ephemeral_names,
304                    &reputation_cfg,
305                )
306            })
307            .await
308            .map_err(|e| CommandError(format!("plugin task panicked: {e}")))
309        })
310    }
311
312    // ----- /acp -----
313
314    fn handle_acp<'a>(
315        &'a mut self,
316        args: &'a str,
317    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
318        Box::pin(async move {
319            self.handle_acp_as_string(args)
320                .map_err(|e| CommandError::new(e.to_string()))
321        })
322    }
323
324    // ----- /cocoon -----
325
326    #[cfg(feature = "cocoon")]
327    delegate_cmd!(handle_cocoon, handle_cocoon_as_string, args: &'a str => String);
328
329    #[cfg(not(feature = "cocoon"))]
330    fn handle_cocoon<'a>(
331        &'a mut self,
332        _args: &'a str,
333    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
334        Box::pin(async {
335            Ok("Cocoon support is not compiled in. Rebuild with `--features cocoon`.".to_owned())
336        })
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::super::agent_tests::{
343        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
344    };
345    use super::*;
346
347    fn cfg_default() -> zeph_config::AcpConfig {
348        zeph_config::AcpConfig::default()
349    }
350
351    fn cfg_with_dirs(dirs: &[&str]) -> zeph_config::AcpConfig {
352        let mut cfg = cfg_default();
353        cfg.additional_directories = dirs
354            .iter()
355            .map(|p| {
356                zeph_config::AdditionalDir::parse(
357                    std::path::Path::new(p)
358                        .canonicalize()
359                        .unwrap_or_else(|_| std::path::PathBuf::from(p)),
360                )
361                .unwrap_or_else(|_| panic!("failed to parse {p}"))
362            })
363            .collect();
364        cfg
365    }
366
367    #[test]
368    fn dirs_empty() {
369        let out = format_acp_dirs(&cfg_default());
370        assert!(out.contains("(none configured)"), "got: {out}");
371    }
372
373    #[test]
374    fn dirs_populated() {
375        // Use a real directory so canonicalize succeeds on all platforms.
376        // Compare against the canonical form to handle macOS /tmp→/private/tmp
377        // and Windows \\?\ extended-length prefix transparently.
378        let tmp_dir = tempfile::tempdir().expect("tempdir");
379        let canonical =
380            std::fs::canonicalize(tmp_dir.path()).unwrap_or_else(|_| tmp_dir.path().to_owned());
381        let canonical_str = canonical.to_string_lossy();
382        let cfg = cfg_with_dirs(&[canonical_str.as_ref()]);
383        let out = format_acp_dirs(&cfg);
384        assert!(out.contains(canonical_str.as_ref()), "got: {out}");
385        assert!(!out.contains("(none configured)"), "got: {out}");
386    }
387
388    #[test]
389    fn auth_methods_default() {
390        let out = format_acp_auth_methods(&cfg_default());
391        assert!(out.contains("agent"), "got: {out}");
392        assert!(!out.contains("Agent"), "got: {out}");
393    }
394
395    #[test]
396    fn auth_methods_empty() {
397        let mut cfg = cfg_default();
398        cfg.auth_methods.clear();
399        let out = format_acp_auth_methods(&cfg);
400        assert!(out.contains("(none configured)"), "got: {out}");
401    }
402
403    #[test]
404    fn status_disabled() {
405        let out = format_acp_status(&cfg_default(), false);
406        assert!(out.contains("ACP: disabled"), "got: {out}");
407        assert!(out.contains("non-ACP"), "got: {out}");
408    }
409
410    #[test]
411    fn status_enabled_acp_session() {
412        let mut cfg = cfg_default();
413        cfg.enabled = true;
414        let out = format_acp_status(&cfg, true);
415        assert!(out.contains("ACP: enabled"), "got: {out}");
416        assert!(out.contains("ACP client"), "got: {out}");
417    }
418
419    #[test]
420    fn empty_args_returns_help() {
421        let out = dispatch_acp(&cfg_default(), false, "").unwrap();
422        assert!(out.contains("Usage: /acp"), "got: {out}");
423        assert!(out.contains("dirs"), "got: {out}");
424        assert!(out.contains("auth-methods"), "got: {out}");
425        assert!(out.contains("status"), "got: {out}");
426    }
427
428    #[test]
429    fn unknown_subcommand_returns_err() {
430        let err = dispatch_acp(&cfg_default(), false, "bogus").unwrap_err();
431        let msg = err.to_string();
432        assert!(msg.contains("bogus"), "got: {msg}");
433        assert!(
434            !msg.contains("\"bogus\""),
435            "should not quote arg, got: {msg}"
436        );
437        assert!(
438            msg.contains("dirs"),
439            "should list valid subcommands, got: {msg}"
440        );
441    }
442
443    #[test]
444    fn whitespace_args_returns_help() {
445        let out = dispatch_acp(&cfg_default(), false, "   ").unwrap();
446        assert!(out.contains("Usage: /acp"), "got: {out}");
447    }
448
449    // R-4706/R-4709: when semantic_scan is enabled but semantic_scan_provider is empty,
450    // `plugin add` must return a CommandError immediately (fail-closed). Before this fix
451    // the code fell through to resolve_background_provider which silently used the primary
452    // provider, bypassing the intent that an unconfigured scanner means "do not proceed".
453    #[tokio::test]
454    async fn plugin_add_semantic_scan_enabled_empty_provider_returns_error() {
455        let mut agent = Agent::new(
456            mock_provider(vec![]),
457            MockChannel::new(vec![]),
458            create_test_registry(),
459            None,
460            5,
461            MockToolExecutor::no_tools(),
462        )
463        .with_semantic_scan(true, "");
464
465        let result = agent.handle_plugins("add some-plugin").await;
466        assert!(
467            result.is_err(),
468            "expected CommandError for missing semantic_scan_provider, got: {result:?}"
469        );
470        let msg = result.unwrap_err().to_string();
471        assert!(
472            msg.contains("semantic_scan_provider"),
473            "error message must mention semantic_scan_provider, got: {msg}"
474        );
475    }
476
477    // R-4706/R-4709: when semantic_scan is disabled, plugin subcommands must proceed
478    // normally regardless of whether semantic_scan_provider is set.
479    #[tokio::test]
480    async fn plugin_list_semantic_scan_disabled_succeeds() {
481        let mut agent = Agent::new(
482            mock_provider(vec![]),
483            MockChannel::new(vec![]),
484            create_test_registry(),
485            None,
486            5,
487            MockToolExecutor::no_tools(),
488        )
489        .with_semantic_scan(false, "");
490
491        // "list" does not trigger scan logic; it should succeed without error.
492        let result = agent.handle_plugins("list").await;
493        assert!(
494            result.is_ok(),
495            "plugin list must succeed when semantic_scan is disabled, got: {result:?}"
496        );
497    }
498
499    // R-4706/R-4709: "plugin add" with semantic_scan disabled must reach the install path
500    // rather than return a scan-related error. The install itself may fail (no real plugin
501    // source), but it must NOT fail with the fail-closed error message.
502    #[tokio::test]
503    async fn plugin_add_semantic_scan_disabled_no_scan_error() {
504        let mut agent = Agent::new(
505            mock_provider(vec![]),
506            MockChannel::new(vec![]),
507            create_test_registry(),
508            None,
509            5,
510            MockToolExecutor::no_tools(),
511        )
512        .with_semantic_scan(false, "");
513
514        let result = agent.handle_plugins("add some-plugin").await;
515        // The call may succeed or fail for unrelated reasons (no real plugin source),
516        // but must NOT fail with the fail-closed error about semantic_scan_provider.
517        if let Err(ref e) = result {
518            assert!(
519                !e.to_string().contains("semantic_scan_provider"),
520                "must not fail with scan error when semantic_scan is disabled, got: {e}"
521            );
522        }
523    }
524
525    // R-4705: semantic_scan_plugin_add must scan all skills concurrently and return
526    // None when every scanner call returns Allow. Verifies buffer_unordered path
527    // processes N inputs without sequential bottleneck.
528    #[tokio::test]
529    async fn semantic_scan_plugin_add_concurrent_all_allow_returns_none() {
530        use zeph_llm::any::AnyProvider;
531        use zeph_llm::mock::MockProvider;
532        use zeph_skills::semantic_scanner::SkillSemanticScanner;
533
534        // MockProvider returns `{"verdict":"allow","reason":"ok"}` for every call.
535        let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
536        let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
537            allow_json.clone(),
538            allow_json,
539        ]));
540        let scanner = SkillSemanticScanner::new(provider);
541
542        // Build a minimal plugin layout with two skills so scan_targets returns
543        // two SkillScanInput entries.
544        let tmp = tempfile::tempdir().unwrap();
545        let plugin_toml = r#"
546[plugin]
547name = "test-plugin"
548version = "0.1.0"
549description = "test"
550
551[[skills]]
552path = "skill-a"
553
554[[skills]]
555path = "skill-b"
556"#;
557        std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
558        for name in ["skill-a", "skill-b"] {
559            let skill_dir = tmp.path().join(name);
560            std::fs::create_dir_all(&skill_dir).unwrap();
561            std::fs::write(
562                skill_dir.join("SKILL.md"),
563                format!("# {name}\n\n## Purpose\nTest skill.\n"),
564            )
565            .unwrap();
566        }
567
568        let result =
569            semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
570                .await;
571
572        // All skills allowed → no error message returned.
573        assert!(result.is_ok(), "expected Ok, got: {result:?}");
574        assert!(
575            result.unwrap().is_none(),
576            "expected None (all passed) but got Some(err)"
577        );
578    }
579
580    // R-4705 regression: buffer_unordered yields in completion order, not input order.
581    // A Block verdict on the *second* skill (index 1) must name that second skill, not the
582    // first. Before the fix, the code zipped verdicts against scan_inputs by position and
583    // discarded the tuple's skill_name, so the wrong skill was reported.
584    #[tokio::test]
585    async fn semantic_scan_plugin_add_block_names_correct_skill() {
586        use zeph_llm::any::AnyProvider;
587        use zeph_llm::mock::MockProvider;
588        use zeph_skills::semantic_scanner::SkillSemanticScanner;
589
590        // First call returns Allow, second returns Block — only the second skill is rejected.
591        let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
592        let block_json = r#"{"verdict":"block","reason":"malicious"}"#.to_owned();
593        let provider =
594            AnyProvider::Mock(MockProvider::with_responses(vec![allow_json, block_json]));
595        let scanner = SkillSemanticScanner::new(provider);
596
597        let tmp = tempfile::tempdir().unwrap();
598        let plugin_toml = r#"
599[plugin]
600name = "test-plugin-block"
601version = "0.1.0"
602description = "test"
603
604[[skills]]
605path = "skill-first"
606
607[[skills]]
608path = "skill-second"
609"#;
610        std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
611        for name in ["skill-first", "skill-second"] {
612            let skill_dir = tmp.path().join(name);
613            std::fs::create_dir_all(&skill_dir).unwrap();
614            std::fs::write(
615                skill_dir.join("SKILL.md"),
616                format!("# {name}\n\n## Purpose\nTest skill.\n"),
617            )
618            .unwrap();
619        }
620
621        let result =
622            semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
623                .await;
624
625        assert!(result.is_ok(), "expected Ok(_), got: {result:?}");
626        let msg = result
627            .unwrap()
628            .expect("expected Some(err) for blocked skill");
629        assert!(
630            msg.contains("skill-second"),
631            "rejection must name the blocked skill 'skill-second', got: {msg}"
632        );
633        assert!(
634            !msg.contains("skill-first"),
635            "rejection must NOT name the allowed skill 'skill-first', got: {msg}"
636        );
637    }
638
639    // R-4706/R-4709: unknown provider name must also fail-closed rather than silently
640    // falling back to the primary provider via resolve_background_provider.
641    #[tokio::test]
642    async fn plugin_add_semantic_scan_unknown_provider_returns_error() {
643        let mut agent = Agent::new(
644            mock_provider(vec![]),
645            MockChannel::new(vec![]),
646            create_test_registry(),
647            None,
648            5,
649            MockToolExecutor::no_tools(),
650        )
651        .with_semantic_scan(true, "nonexistent_provider");
652
653        let result = agent.handle_plugins("add some-plugin").await;
654        assert!(
655            result.is_err(),
656            "expected CommandError for unknown semantic_scan_provider, got: {result:?}"
657        );
658        let msg = result.unwrap_err().to_string();
659        assert!(
660            msg.contains("semantic_scan_provider"),
661            "error message must mention semantic_scan_provider, got: {msg}"
662        );
663    }
664}