feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills → capacités agent découvrables), #122 (override permissions par défaut), #131 (effort par agent/presets) et #132 (outil MCP d'édition du contexte projet) sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs, backend/dto.rs), inséparable sans staging interactif (indisponible ici). Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée. NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
@ -11,7 +11,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||
PluginStoreError,
|
||||
};
|
||||
use domain::{
|
||||
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
||||
@ -23,6 +24,14 @@ use tokio::process::Child;
|
||||
|
||||
const REGISTRY_FILE: &str = "registry.json";
|
||||
const MANIFEST_FILE: &str = "idea-plugin.json";
|
||||
const STORAGE_ENTRIES_DIR: &str = "entries";
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginStorageEntry {
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Filesystem package store under app-data `plugins/`.
|
||||
#[derive(Debug, Clone)]
|
||||
@ -151,18 +160,7 @@ impl PluginPackageStore for FsPluginPackageStore {
|
||||
archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
let stage = self.stage_root()?;
|
||||
let status = std::process::Command::new("unzip")
|
||||
.arg("-q")
|
||||
.arg(archive.as_str())
|
||||
.arg("-d")
|
||||
.arg(&stage)
|
||||
.status()
|
||||
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(PluginStoreError::Format(format!(
|
||||
"unzip exited with status {status}"
|
||||
)));
|
||||
}
|
||||
extract_archive_confined(Path::new(archive.as_str()), &stage)?;
|
||||
ensure_manifest(&stage)?;
|
||||
let content_hash = hash_dir(&stage)?;
|
||||
Ok(StagedPluginPackage {
|
||||
@ -278,6 +276,43 @@ fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_archive_confined(archive: &Path, stage: &Path) -> Result<(), PluginStoreError> {
|
||||
let file = fs::File::open(archive).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| PluginStoreError::Format(format!("invalid zip archive: {e}")))?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|e| PluginStoreError::Format(format!("invalid zip entry: {e}")))?;
|
||||
let entry_name = entry.name().to_owned();
|
||||
let enclosed = entry.enclosed_name().ok_or_else(|| {
|
||||
PluginStoreError::Invalid(format!("archive entry escapes plugin root: {entry_name}"))
|
||||
})?;
|
||||
if entry
|
||||
.unix_mode()
|
||||
.is_some_and(|mode| mode & 0o170000 == 0o120000)
|
||||
{
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"archive entry symlinks are not allowed: {entry_name}"
|
||||
)));
|
||||
}
|
||||
let destination = stage.join(&enclosed);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
let mut out =
|
||||
fs::File::create(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
io::copy(&mut entry, &mut out).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
out.flush()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||
@ -290,6 +325,16 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
copy_dir_all(&entry.path(), &dest)?;
|
||||
} else if ty.is_file() {
|
||||
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
} else if ty.is_symlink() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source contains symlink: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
} else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source contains unsupported entry: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -329,6 +374,16 @@ fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStor
|
||||
collect_files(&entry.path(), files)?;
|
||||
} else if ty.is_file() {
|
||||
files.push(entry.path());
|
||||
} else if ty.is_symlink() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin package contains symlink: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
} else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin package contains unsupported entry: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -354,6 +409,97 @@ impl FsPluginRegistryStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem plugin-owned storage under app-data `plugins/data/<pluginId>/`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPluginStorageStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsPluginStorageStore {
|
||||
/// Builds the store.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
root: app_data_dir.into().join("plugins").join("data"),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
|
||||
self.root.join(plugin_id.as_str())
|
||||
}
|
||||
|
||||
fn entry_path(&self, plugin_id: &PluginId, key: &str) -> PathBuf {
|
||||
let digest = Sha256::digest(key.as_bytes());
|
||||
self.plugin_dir(plugin_id)
|
||||
.join(STORAGE_ENTRIES_DIR)
|
||||
.join(format!("{}.json", hex::encode(digest)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStorageStore for FsPluginStorageStore {
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|e| PluginStorageError::Io(e.to_string()))?;
|
||||
let entry: PluginStorageEntry = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
|
||||
if entry.key == key {
|
||||
Ok(Some(entry.value))
|
||||
} else {
|
||||
Err(PluginStorageError::Invalid(
|
||||
"plugin storage key hash collision".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| PluginStorageError::Io(e.to_string()))?;
|
||||
}
|
||||
let entry = PluginStorageEntry {
|
||||
key: key.to_owned(),
|
||||
value,
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&entry)
|
||||
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
|
||||
fs::write(path, bytes).map_err(|e| PluginStorageError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(PluginStorageError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError> {
|
||||
let dir = self.plugin_dir(plugin_id);
|
||||
match fs::remove_dir_all(dir) {
|
||||
Ok(()) => Ok(RemovalOutcome::Removed),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemovalOutcome::NotFound),
|
||||
Err(e) => Err(PluginStorageError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FsPluginRegistryStore {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
@ -574,6 +720,99 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct ZipEntrySpec<'a> {
|
||||
name: &'a str,
|
||||
contents: &'a [u8],
|
||||
unix_mode: Option<u32>,
|
||||
}
|
||||
|
||||
fn write_u16(out: &mut Vec<u8>, value: u16) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for &byte in bytes {
|
||||
crc ^= u32::from(byte);
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (0xedb8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
fn write_zip(path: &Path, entries: &[ZipEntrySpec<'_>]) {
|
||||
let mut file = fs::File::create(path).unwrap();
|
||||
let mut central = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
for entry in entries {
|
||||
let name = entry.name.as_bytes();
|
||||
let data = entry.contents;
|
||||
let crc = crc32(data);
|
||||
let local_size = 30u32 + name.len() as u32 + data.len() as u32;
|
||||
|
||||
let mut local = Vec::new();
|
||||
write_u32(&mut local, 0x0403_4b50);
|
||||
write_u16(&mut local, 20);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u32(&mut local, crc);
|
||||
write_u32(&mut local, data.len() as u32);
|
||||
write_u32(&mut local, data.len() as u32);
|
||||
write_u16(&mut local, name.len() as u16);
|
||||
write_u16(&mut local, 0);
|
||||
local.extend_from_slice(name);
|
||||
local.extend_from_slice(data);
|
||||
file.write_all(&local).unwrap();
|
||||
|
||||
let mut header = Vec::new();
|
||||
let version_made_by = if entry.unix_mode.is_some() {
|
||||
(3u16 << 8) | 20
|
||||
} else {
|
||||
20
|
||||
};
|
||||
let external_attributes = entry.unix_mode.unwrap_or(0) << 16;
|
||||
write_u32(&mut header, 0x0201_4b50);
|
||||
write_u16(&mut header, version_made_by);
|
||||
write_u16(&mut header, 20);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u32(&mut header, crc);
|
||||
write_u32(&mut header, data.len() as u32);
|
||||
write_u32(&mut header, data.len() as u32);
|
||||
write_u16(&mut header, name.len() as u16);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u32(&mut header, external_attributes);
|
||||
write_u32(&mut header, offset);
|
||||
header.extend_from_slice(name);
|
||||
central.extend_from_slice(&header);
|
||||
offset += local_size;
|
||||
}
|
||||
file.write_all(¢ral).unwrap();
|
||||
let mut eocd = Vec::new();
|
||||
write_u32(&mut eocd, 0x0605_4b50);
|
||||
write_u16(&mut eocd, 0);
|
||||
write_u16(&mut eocd, 0);
|
||||
write_u16(&mut eocd, entries.len() as u16);
|
||||
write_u16(&mut eocd, entries.len() as u16);
|
||||
write_u32(&mut eocd, central.len() as u32);
|
||||
write_u32(&mut eocd, offset);
|
||||
write_u16(&mut eocd, 0);
|
||||
file.write_all(&eocd).unwrap();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingBridge {
|
||||
started: Mutex<Vec<PluginMcpServerSpec>>,
|
||||
@ -671,35 +910,23 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_archive_without_path_escape() {
|
||||
if std::process::Command::new("zip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
|| std::process::Command::new("unzip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let app = temp_dir("archive-app");
|
||||
let source = temp_dir("archive-source");
|
||||
write_plugin(&source, "bundle");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
{
|
||||
let status = std::process::Command::new("zip")
|
||||
.arg("-qr")
|
||||
.arg(&archive_path)
|
||||
.arg(".")
|
||||
.current_dir(&source)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "dist/index.js",
|
||||
contents: b"bundle",
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(app.join("data"));
|
||||
let staged = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
@ -707,9 +934,142 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_directory_rejects_source_symlink() {
|
||||
let app = temp_dir("symlink-app");
|
||||
let source = temp_dir("symlink-source");
|
||||
write_plugin(&source, "bundle");
|
||||
let outside = app.join("outside.txt");
|
||||
fs::write(&outside, "secret").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, source.join("dist/escape.txt")).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::os::windows::fs::symlink_file(&outside, source.join("dist/escape.txt")).unwrap();
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("symlink"));
|
||||
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_archive_rejects_parent_traversal_without_writing_outside_stage() {
|
||||
let app = temp_dir("traversal-app");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "../../../../outside.txt",
|
||||
contents: b"pwned",
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("escapes plugin root"));
|
||||
assert!(!app.join("outside.txt").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_archive_rejects_symlink_entries() {
|
||||
let app = temp_dir("archive-symlink-app");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "dist/link.js",
|
||||
contents: b"/tmp/outside.js",
|
||||
unix_mode: Some(0o120777),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("symlinks are not allowed"));
|
||||
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_store_round_trips_deletes_and_purges_plugin_data() {
|
||||
let app = temp_dir("storage-app");
|
||||
let store = FsPluginStorageStore::new(&app);
|
||||
let plugin_id = PluginId::new("dev.acme.test").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
None
|
||||
);
|
||||
store
|
||||
.set(
|
||||
&plugin_id,
|
||||
"helloPlugin.launches",
|
||||
serde_json::json!({"count": 1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
Some(serde_json::json!({"count": 1}))
|
||||
);
|
||||
assert!(app.join("plugins/data/dev.acme.test/entries").is_dir());
|
||||
|
||||
assert!(store
|
||||
.delete(&plugin_id, "helloPlugin.launches")
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
None
|
||||
);
|
||||
store
|
||||
.set(&plugin_id, "helloPlugin.enabled", serde_json::json!(true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.purge_plugin(&plugin_id).await.unwrap(),
|
||||
RemovalOutcome::Removed
|
||||
);
|
||||
assert!(!app.join("plugins/data/dev.acme.test").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
||||
let bridge = Arc::new(RecordingBridge::default());
|
||||
|
||||
Reference in New Issue
Block a user