b14e42d9bb
Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/233 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
2213 lines
81 KiB
Rust
2213 lines
81 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Mutex, OnceLock};
|
|
use tauri::Manager;
|
|
use tauri_plugin_dialog::DialogExt;
|
|
use zip::ZipArchive;
|
|
|
|
const CLIENT_EXTENSIONS_DIR_NAME: &str = "extensions";
|
|
const CLIENT_EXTENSIONS_INDEX_FILE_NAME: &str = "index.json";
|
|
const CLIENT_EXTENSIONS_SCHEMA_VERSION: &str = "direct-project-client-extensions.v1";
|
|
const CLIENT_EXTENSIONS_SOURCES_DIR_NAME: &str = "sources";
|
|
const RESERVED_MCP_SERVER_NAME: &str = "agc_tools";
|
|
static CLIENT_EXTENSIONS_INDEX_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
static CLIENT_MCP_CONNECTION_OWNERS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct ClientMcpRuntimeServer {
|
|
pub(crate) extension_id: String,
|
|
pub(crate) name: String,
|
|
pub(crate) config: BTreeMap<String, toml::Value>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct ClientExtensionItem {
|
|
pub(crate) id: String,
|
|
pub(crate) name: String,
|
|
pub(crate) original_name: String,
|
|
pub(crate) extension_type: String,
|
|
pub(crate) source_name: String,
|
|
pub(crate) source_relative_path: String,
|
|
pub(crate) enabled: bool,
|
|
pub(crate) status: String,
|
|
pub(crate) last_error: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct ClientExtensionImportResult {
|
|
pub(crate) imported: Vec<ClientExtensionItem>,
|
|
pub(crate) source_name: String,
|
|
pub(crate) renamed: bool,
|
|
pub(crate) duplicate: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ClientExtensionIndex {
|
|
schema_version: String,
|
|
sources: Vec<ImportedExtensionSource>,
|
|
items: Vec<StoredExtensionItem>,
|
|
}
|
|
|
|
impl Default for ClientExtensionIndex {
|
|
fn default() -> Self {
|
|
Self {
|
|
schema_version: CLIENT_EXTENSIONS_SCHEMA_VERSION.to_string(),
|
|
sources: Vec::new(),
|
|
items: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ImportedExtensionSource {
|
|
id: String,
|
|
original_name: String,
|
|
storage_path: String,
|
|
fingerprint: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct StoredExtensionItem {
|
|
id: String,
|
|
source_id: String,
|
|
extension_type: String,
|
|
name: String,
|
|
original_name: String,
|
|
source_relative_path: String,
|
|
enabled: bool,
|
|
fingerprint: String,
|
|
last_error: Option<String>,
|
|
#[serde(default)]
|
|
mcp_config: Option<serde_json::Value>,
|
|
}
|
|
|
|
struct ImportedCandidate {
|
|
extension_type: String,
|
|
original_name: String,
|
|
source_relative_path: String,
|
|
fingerprint: String,
|
|
mcp_config: Option<serde_json::Value>,
|
|
}
|
|
|
|
fn extensions_root() -> Result<PathBuf, String> {
|
|
let config_path = crate::writable_game_creator_config_path()?;
|
|
let config_dir = config_path
|
|
.parent()
|
|
.ok_or_else(|| "客户端配置文件缺少父目录".to_string())?;
|
|
let root = config_dir.join(CLIENT_EXTENSIONS_DIR_NAME);
|
|
crate::ensure_game_creator_private_directory_tree(&root, "客户端扩展目录")
|
|
.map_err(|error| format!("准备客户端扩展目录失败:{error}"))?;
|
|
crate::ensure_game_creator_private_directory_tree(
|
|
&root.join(CLIENT_EXTENSIONS_SOURCES_DIR_NAME),
|
|
"客户端扩展来源目录",
|
|
)
|
|
.map_err(|error| format!("准备客户端扩展来源目录失败:{error}"))?;
|
|
Ok(root)
|
|
}
|
|
|
|
fn index_path(root: &Path) -> PathBuf {
|
|
root.join(CLIENT_EXTENSIONS_INDEX_FILE_NAME)
|
|
}
|
|
|
|
fn read_index(root: &Path) -> Result<ClientExtensionIndex, String> {
|
|
let path = index_path(root);
|
|
if !path.exists() {
|
|
return Ok(ClientExtensionIndex::default());
|
|
}
|
|
let content =
|
|
crate::read_game_creator_private_file_to_string(&path, "客户端扩展索引", 4 * 1024 * 1024)?;
|
|
let index = serde_json::from_str::<ClientExtensionIndex>(&content)
|
|
.map_err(|error| format!("解析客户端扩展索引失败:{error}"))?;
|
|
if index.schema_version != CLIENT_EXTENSIONS_SCHEMA_VERSION {
|
|
return Err(format!(
|
|
"客户端扩展索引版本不支持:{}",
|
|
index.schema_version
|
|
));
|
|
}
|
|
Ok(index)
|
|
}
|
|
|
|
fn write_index(root: &Path, index: &ClientExtensionIndex) -> Result<(), String> {
|
|
let content = serde_json::to_string_pretty(index)
|
|
.map_err(|error| format!("序列化客户端扩展索引失败:{error}"))?;
|
|
crate::write_game_creator_config_atomically(&index_path(root), &format!("{content}\n"))
|
|
}
|
|
|
|
fn client_extensions_index_lock() -> &'static Mutex<()> {
|
|
CLIENT_EXTENSIONS_INDEX_LOCK.get_or_init(|| Mutex::new(()))
|
|
}
|
|
|
|
fn client_mcp_connection_owners() -> &'static Mutex<HashMap<String, String>> {
|
|
CLIENT_MCP_CONNECTION_OWNERS.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
pub(crate) fn claim_client_mcp_connection(
|
|
extension_ids: impl IntoIterator<Item = String>,
|
|
connection_id: &str,
|
|
) -> Result<(), String> {
|
|
let root = extensions_root()?;
|
|
claim_client_mcp_connection_at(&root, extension_ids, connection_id)
|
|
}
|
|
|
|
fn claim_client_mcp_connection_at(
|
|
root: &Path,
|
|
extension_ids: impl IntoIterator<Item = String>,
|
|
connection_id: &str,
|
|
) -> Result<(), String> {
|
|
let _index_guard = client_extensions_index_lock()
|
|
.lock()
|
|
.map_err(|_| "客户端扩展索引锁已损坏".to_string())?;
|
|
let index = read_index(&root)?;
|
|
let enabled_ids = index
|
|
.items
|
|
.iter()
|
|
.filter(|item| item.extension_type == "mcp" && item.enabled)
|
|
.map(|item| item.id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
let mut owners = client_mcp_connection_owners()
|
|
.lock()
|
|
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?;
|
|
for extension_id in extension_ids {
|
|
if enabled_ids.contains(extension_id.as_str()) {
|
|
owners.insert(extension_id, connection_id.to_string());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn release_client_mcp_connection(
|
|
extension_ids: impl IntoIterator<Item = String>,
|
|
connection_id: &str,
|
|
) -> Result<(), String> {
|
|
let _index_guard = client_extensions_index_lock()
|
|
.lock()
|
|
.map_err(|_| "客户端扩展索引锁已损坏".to_string())?;
|
|
let mut owners = client_mcp_connection_owners()
|
|
.lock()
|
|
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?;
|
|
for extension_id in extension_ids {
|
|
if owners.get(&extension_id).map(String::as_str) == Some(connection_id) {
|
|
owners.remove(&extension_id);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn invalidate_client_mcp_connection(extension_id: &str) -> Result<(), String> {
|
|
let _index_guard = client_extensions_index_lock()
|
|
.lock()
|
|
.map_err(|_| "客户端扩展索引锁已损坏".to_string())?;
|
|
client_mcp_connection_owners()
|
|
.lock()
|
|
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?
|
|
.remove(extension_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn read_client_extension_index_locked<T>(
|
|
root: &Path,
|
|
reader: impl FnOnce(&Path, &ClientExtensionIndex) -> Result<T, String>,
|
|
) -> Result<T, String> {
|
|
let _guard = client_extensions_index_lock()
|
|
.lock()
|
|
.map_err(|_| "客户端扩展索引锁已损坏".to_string())?;
|
|
let index = read_index(root)?;
|
|
reader(root, &index)
|
|
}
|
|
|
|
fn update_client_extension_index_at<T>(
|
|
root: &Path,
|
|
updater: impl FnOnce(&Path, &mut ClientExtensionIndex) -> Result<(T, bool), String>,
|
|
) -> Result<T, String> {
|
|
let _guard = client_extensions_index_lock()
|
|
.lock()
|
|
.map_err(|_| "客户端扩展索引锁已损坏".to_string())?;
|
|
let mut index = read_index(root)?;
|
|
let (result, changed) = updater(root, &mut index)?;
|
|
if changed {
|
|
write_index(root, &index)?;
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
fn update_client_extension_index<T>(
|
|
updater: impl FnOnce(&Path, &mut ClientExtensionIndex) -> Result<(T, bool), String>,
|
|
) -> Result<T, String> {
|
|
let root = extensions_root()?;
|
|
update_client_extension_index_at(&root, updater)
|
|
}
|
|
|
|
fn new_id(prefix: &str) -> String {
|
|
format!("{}-{}", prefix, uuid::Uuid::new_v4().simple())
|
|
}
|
|
|
|
fn sha256_bytes(bytes: &[u8]) -> String {
|
|
format!("{:x}", Sha256::digest(bytes))
|
|
}
|
|
|
|
fn hash_file(path: &Path) -> Result<String, String> {
|
|
let bytes =
|
|
fs::read(path).map_err(|error| format!("读取扩展文件失败:{}: {error}", path.display()))?;
|
|
Ok(sha256_bytes(&bytes))
|
|
}
|
|
|
|
fn normalize_relative_path(path: &Path) -> Result<String, String> {
|
|
let mut components = Vec::new();
|
|
for component in path.components() {
|
|
match component {
|
|
std::path::Component::Normal(value) => {
|
|
components.push(value.to_string_lossy().into_owned())
|
|
}
|
|
_ => return Err(format!("扩展来源包含无效相对路径:{}", path.display())),
|
|
}
|
|
}
|
|
if components.is_empty() {
|
|
return Err("扩展来源相对路径不能为空".to_string());
|
|
}
|
|
Ok(components.join("/"))
|
|
}
|
|
|
|
fn sorted_directory_files(root: &Path) -> Result<Vec<(String, PathBuf)>, String> {
|
|
let mut entries = Vec::new();
|
|
fn visit(
|
|
root: &Path,
|
|
current: &Path,
|
|
entries: &mut Vec<(String, PathBuf)>,
|
|
) -> Result<(), String> {
|
|
let mut children = fs::read_dir(current)
|
|
.map_err(|error| format!("读取扩展目录失败:{}: {error}", current.display()))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| format!("读取扩展目录项失败:{}: {error}", current.display()))?;
|
|
children.sort_by_key(|entry| entry.file_name());
|
|
for entry in children {
|
|
let path = entry.path();
|
|
let metadata = fs::symlink_metadata(&path)
|
|
.map_err(|error| format!("读取扩展目录项失败:{}: {error}", path.display()))?;
|
|
if metadata.file_type().is_symlink() {
|
|
return Err(format!("扩展目录不能包含符号链接:{}", path.display()));
|
|
}
|
|
if metadata.is_dir() {
|
|
visit(root, &path, entries)?;
|
|
} else if metadata.is_file() {
|
|
let relative = path
|
|
.strip_prefix(root)
|
|
.map_err(|_| format!("解析扩展目录相对路径失败:{}", path.display()))?;
|
|
entries.push((normalize_relative_path(relative)?, path));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
visit(root, root, &mut entries)?;
|
|
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
|
Ok(entries)
|
|
}
|
|
|
|
fn fingerprint_directory(root: &Path) -> Result<String, String> {
|
|
let mut hasher = Sha256::new();
|
|
for (relative, path) in sorted_directory_files(root)? {
|
|
let bytes = fs::read(&path)
|
|
.map_err(|error| format!("读取扩展文件失败:{}: {error}", path.display()))?;
|
|
hasher.update((relative.len() as u64).to_le_bytes());
|
|
hasher.update(relative.as_bytes());
|
|
hasher.update((bytes.len() as u64).to_le_bytes());
|
|
hasher.update(bytes);
|
|
}
|
|
Ok(format!("{:x}", hasher.finalize()))
|
|
}
|
|
|
|
fn copy_file(source: &Path, destination: &Path) -> Result<(), String> {
|
|
if let Some(parent) = destination.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|error| format!("创建扩展存储目录失败:{}: {error}", parent.display()))?;
|
|
}
|
|
fs::copy(source, destination).map_err(|error| {
|
|
format!(
|
|
"保存扩展文件失败:{} -> {}: {error}",
|
|
source.display(),
|
|
destination.display()
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_directory_contents(source: &Path, destination: &Path) -> Result<(), String> {
|
|
for (relative, path) in sorted_directory_files(source)? {
|
|
copy_file(
|
|
&path,
|
|
&destination.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR)),
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_zip(source: &Path, destination: &Path) -> Result<(), String> {
|
|
let file = fs::File::open(source)
|
|
.map_err(|error| format!("打开扩展 zip 失败:{}: {error}", source.display()))?;
|
|
let mut archive = ZipArchive::new(file)
|
|
.map_err(|error| format!("读取扩展 zip 失败:{}: {error}", source.display()))?;
|
|
for index in 0..archive.len() {
|
|
let mut entry = archive
|
|
.by_index(index)
|
|
.map_err(|error| format!("读取扩展 zip 条目失败:{error}"))?;
|
|
let enclosed = entry
|
|
.enclosed_name()
|
|
.ok_or_else(|| "扩展 zip 包含越界路径".to_string())?
|
|
.to_path_buf();
|
|
let output = destination.join(&enclosed);
|
|
if entry.is_dir() {
|
|
fs::create_dir_all(&output)
|
|
.map_err(|error| format!("创建扩展 zip 目录失败:{}: {error}", output.display()))?;
|
|
continue;
|
|
}
|
|
if let Some(parent) = output.parent() {
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
format!("创建扩展 zip 父目录失败:{}: {error}", parent.display())
|
|
})?;
|
|
}
|
|
let mut output_file = fs::File::create(&output)
|
|
.map_err(|error| format!("写入扩展 zip 文件失败:{}: {error}", output.display()))?;
|
|
std::io::copy(&mut entry, &mut output_file)
|
|
.map_err(|error| format!("解压扩展 zip 文件失败:{}: {error}", output.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_skill_name(path: &Path) -> String {
|
|
let fallback = path
|
|
.parent()
|
|
.and_then(Path::file_name)
|
|
.and_then(|value| value.to_str())
|
|
.filter(|value| !value.starts_with(".staging-"))
|
|
.unwrap_or("skill")
|
|
.to_string();
|
|
let Ok(content) = fs::read_to_string(path) else {
|
|
return fallback;
|
|
};
|
|
let mut lines = content.lines();
|
|
if lines.next().map(str::trim) != Some("---") {
|
|
return fallback;
|
|
}
|
|
for line in lines {
|
|
let trimmed = line.trim();
|
|
if trimmed == "---" {
|
|
break;
|
|
}
|
|
if let Some(value) = trimmed.strip_prefix("name:") {
|
|
let value = value.trim().trim_matches(['"', '\'']);
|
|
if !value.is_empty() {
|
|
return value.to_string();
|
|
}
|
|
}
|
|
}
|
|
fallback
|
|
}
|
|
|
|
fn native_name(value: &str) -> String {
|
|
let mut result = String::new();
|
|
let mut last_was_separator = false;
|
|
for character in value.trim().chars() {
|
|
if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
|
|
result.push(character);
|
|
last_was_separator = false;
|
|
} else if !last_was_separator {
|
|
result.push('-');
|
|
last_was_separator = true;
|
|
}
|
|
}
|
|
let result = result.trim_matches('-').to_string();
|
|
if result.is_empty() {
|
|
"extension".to_string()
|
|
} else {
|
|
result
|
|
}
|
|
}
|
|
|
|
fn allocate_name(original_name: &str, existing: &mut BTreeSet<String>) -> (String, bool) {
|
|
allocate_name_with_case_mode(original_name, existing, false)
|
|
}
|
|
|
|
fn allocate_skill_name(original_name: &str, existing: &mut BTreeSet<String>) -> (String, bool) {
|
|
allocate_name_with_case_mode(original_name, existing, true)
|
|
}
|
|
|
|
fn allocate_name_with_case_mode(
|
|
original_name: &str,
|
|
existing: &mut BTreeSet<String>,
|
|
case_insensitive: bool,
|
|
) -> (String, bool) {
|
|
let base = native_name(original_name);
|
|
let conflicts = |candidate: &str| {
|
|
if case_insensitive {
|
|
existing
|
|
.iter()
|
|
.any(|name| name.eq_ignore_ascii_case(candidate))
|
|
} else {
|
|
existing.contains(candidate)
|
|
}
|
|
};
|
|
if !conflicts(&base) {
|
|
existing.insert(base.clone());
|
|
return (base, false);
|
|
}
|
|
let mut suffix = 2_u32;
|
|
loop {
|
|
let candidate = format!("{base}-{suffix}");
|
|
if !conflicts(&candidate) {
|
|
existing.insert(candidate.clone());
|
|
return (candidate, true);
|
|
}
|
|
suffix += 1;
|
|
}
|
|
}
|
|
|
|
fn source_name(path: &Path) -> String {
|
|
path.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or("extension")
|
|
.to_string()
|
|
}
|
|
|
|
fn is_skill_file(path: &Path) -> bool {
|
|
path.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("SKILL.md"))
|
|
}
|
|
|
|
fn parse_mcp_config_file(path: &Path) -> Result<Vec<(String, serde_json::Value)>, String> {
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or_default();
|
|
if file_name.eq_ignore_ascii_case(".mcp.json") {
|
|
let Ok(content) = fs::read_to_string(path) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let Some(servers) = value
|
|
.get("mcpServers")
|
|
.and_then(serde_json::Value::as_object)
|
|
else {
|
|
return Ok(Vec::new());
|
|
};
|
|
return Ok(servers
|
|
.iter()
|
|
.map(|(name, config)| (name.clone(), config.clone()))
|
|
.collect());
|
|
}
|
|
if !file_name.to_ascii_lowercase().ends_with(".toml") {
|
|
return Ok(Vec::new());
|
|
}
|
|
let Ok(content) = fs::read_to_string(path) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let Ok(value) = content.parse::<toml::Value>() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let Some(servers) = value.get("mcp_servers").and_then(toml::Value::as_table) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
servers
|
|
.iter()
|
|
.map(|(name, config)| {
|
|
serde_json::to_value(config)
|
|
.map(|value| (name.clone(), value))
|
|
.map_err(|error| format!("转换 MCP TOML 配置失败:{}: {error}", path.display()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn discover_candidates(payload: &Path) -> Result<Vec<ImportedCandidate>, String> {
|
|
let files = sorted_directory_files(payload)?;
|
|
let mut candidates = Vec::new();
|
|
for (relative, path) in files {
|
|
if is_skill_file(&path) {
|
|
candidates.push(ImportedCandidate {
|
|
extension_type: "skill".to_string(),
|
|
original_name: parse_skill_name(&path),
|
|
source_relative_path: relative,
|
|
fingerprint: hash_file(&path)?,
|
|
mcp_config: None,
|
|
});
|
|
continue;
|
|
}
|
|
for (name, config) in parse_mcp_config_file(&path)? {
|
|
let config_bytes = serde_json::to_vec(&config)
|
|
.map_err(|error| format!("序列化 MCP 配置失败:{error}"))?;
|
|
candidates.push(ImportedCandidate {
|
|
extension_type: "mcp".to_string(),
|
|
original_name: name,
|
|
source_relative_path: relative.clone(),
|
|
fingerprint: sha256_bytes(&config_bytes),
|
|
mcp_config: Some(config),
|
|
});
|
|
}
|
|
}
|
|
Ok(candidates)
|
|
}
|
|
|
|
fn prepare_payload(source: &Path, staging: &Path) -> Result<String, String> {
|
|
let metadata = fs::metadata(source)
|
|
.map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?;
|
|
fs::create_dir_all(staging)
|
|
.map_err(|error| format!("创建扩展暂存目录失败:{}: {error}", staging.display()))?;
|
|
if metadata.is_dir() {
|
|
copy_directory_contents(source, staging)?;
|
|
return fingerprint_directory(source);
|
|
}
|
|
if !metadata.is_file() {
|
|
return Err("扩展来源必须是普通文件或目录".to_string());
|
|
}
|
|
let fingerprint = hash_file(source)?;
|
|
let file_name = source_name(source);
|
|
if file_name.to_ascii_lowercase().ends_with(".zip") {
|
|
match extract_zip(source, staging) {
|
|
Ok(()) => {}
|
|
Err(error) if error.contains("越界") => return Err(error),
|
|
Err(_) => {
|
|
fs::remove_dir_all(staging).map_err(|error| {
|
|
format!(
|
|
"清理无效扩展 zip 暂存目录失败:{}: {error}",
|
|
staging.display()
|
|
)
|
|
})?;
|
|
fs::create_dir_all(staging).map_err(|error| {
|
|
format!("重建扩展 zip 暂存目录失败:{}: {error}", staging.display())
|
|
})?;
|
|
copy_file(source, &staging.join(&file_name))?;
|
|
}
|
|
}
|
|
} else {
|
|
copy_file(source, &staging.join(&file_name))?;
|
|
}
|
|
Ok(fingerprint)
|
|
}
|
|
|
|
fn stored_item_view(
|
|
index: &ClientExtensionIndex,
|
|
item: &StoredExtensionItem,
|
|
) -> ClientExtensionItem {
|
|
let source_name = index
|
|
.sources
|
|
.iter()
|
|
.find(|source| source.id == item.source_id)
|
|
.map(|source| source.original_name.clone())
|
|
.unwrap_or_else(|| "未知来源".to_string());
|
|
let status = if item.extension_type == "unknown" {
|
|
"unknown"
|
|
} else if item.last_error.is_some() {
|
|
"startup-failed"
|
|
} else if item.enabled {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
};
|
|
ClientExtensionItem {
|
|
id: item.id.clone(),
|
|
name: item.name.clone(),
|
|
original_name: item.original_name.clone(),
|
|
extension_type: item.extension_type.clone(),
|
|
source_name,
|
|
source_relative_path: item.source_relative_path.clone(),
|
|
enabled: item.enabled,
|
|
status: status.to_string(),
|
|
last_error: item.last_error.clone(),
|
|
}
|
|
}
|
|
|
|
fn client_skill_set_fingerprint(index: &ClientExtensionIndex) -> String {
|
|
let mut entries = index
|
|
.items
|
|
.iter()
|
|
.filter(|item| item.extension_type == "skill" && item.enabled)
|
|
.map(|item| {
|
|
(
|
|
item.name.clone(),
|
|
item.source_relative_path.clone(),
|
|
item.fingerprint.clone(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
entries.sort();
|
|
|
|
let mut digest = Sha256::new();
|
|
digest.update(b"direct-project-client-skills.v1\0");
|
|
for (name, source_relative_path, fingerprint) in entries {
|
|
for value in [name, source_relative_path, fingerprint] {
|
|
digest.update((value.len() as u64).to_le_bytes());
|
|
digest.update(value.as_bytes());
|
|
}
|
|
}
|
|
format!("{:x}", digest.finalize())
|
|
}
|
|
|
|
fn client_mcp_set_fingerprint(index: &ClientExtensionIndex) -> String {
|
|
let mut entries = index
|
|
.items
|
|
.iter()
|
|
.filter(|item| item.extension_type == "mcp" && item.enabled)
|
|
.map(|item| {
|
|
(
|
|
item.id.clone(),
|
|
item.source_id.clone(),
|
|
item.name.clone(),
|
|
item.source_relative_path.clone(),
|
|
item.fingerprint.clone(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
entries.sort();
|
|
|
|
let mut digest = Sha256::new();
|
|
digest.update(b"direct-project-client-mcp.v1\0");
|
|
for (id, source_id, name, source_relative_path, fingerprint) in entries {
|
|
for value in [id, source_id, name, source_relative_path, fingerprint] {
|
|
digest.update((value.len() as u64).to_le_bytes());
|
|
digest.update(value.as_bytes());
|
|
}
|
|
}
|
|
format!("{:x}", digest.finalize())
|
|
}
|
|
|
|
pub(crate) fn enabled_client_skill_fingerprint() -> Result<String, String> {
|
|
let root = extensions_root()?;
|
|
read_client_extension_index_locked(&root, |_, index| Ok(client_skill_set_fingerprint(index)))
|
|
}
|
|
|
|
pub(crate) fn enabled_client_mcp_fingerprint() -> Result<String, String> {
|
|
let root = extensions_root()?;
|
|
read_client_extension_index_locked(&root, |_, index| Ok(client_mcp_set_fingerprint(index)))
|
|
}
|
|
|
|
fn mcp_config_field<'a>(
|
|
config: &'a serde_json::Map<String, serde_json::Value>,
|
|
names: &[&str],
|
|
) -> Option<&'a serde_json::Value> {
|
|
names.iter().find_map(|name| config.get(*name))
|
|
}
|
|
|
|
fn mcp_string_field(
|
|
config: &serde_json::Map<String, serde_json::Value>,
|
|
names: &[&str],
|
|
label: &str,
|
|
) -> Result<Option<String>, String> {
|
|
let Some(value) = mcp_config_field(config, names) else {
|
|
return Ok(None);
|
|
};
|
|
value
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(Some)
|
|
.ok_or_else(|| format!("MCP 配置字段 {label} 必须是非空字符串"))
|
|
}
|
|
|
|
fn mcp_toml_field(
|
|
config: &serde_json::Map<String, serde_json::Value>,
|
|
names: &[&str],
|
|
label: &str,
|
|
) -> Result<Option<toml::Value>, String> {
|
|
let Some(value) = mcp_config_field(config, names) else {
|
|
return Ok(None);
|
|
};
|
|
toml::Value::try_from(value.clone())
|
|
.map(Some)
|
|
.map_err(|_| format!("MCP 配置字段 {label} 无法转换为原生 TOML"))
|
|
}
|
|
|
|
fn mcp_string_array_field(
|
|
config: &serde_json::Map<String, serde_json::Value>,
|
|
names: &[&str],
|
|
label: &str,
|
|
) -> Result<Option<toml::Value>, String> {
|
|
let value = mcp_toml_field(config, names, label)?;
|
|
if value.as_ref().is_some_and(|value| {
|
|
!value
|
|
.as_array()
|
|
.is_some_and(|values| values.iter().all(|value| value.is_str()))
|
|
}) {
|
|
return Err(format!("MCP 配置字段 {label} 必须是字符串数组"));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn mcp_string_table_field(
|
|
config: &serde_json::Map<String, serde_json::Value>,
|
|
names: &[&str],
|
|
label: &str,
|
|
) -> Result<Option<toml::Value>, String> {
|
|
let value = mcp_toml_field(config, names, label)?;
|
|
if value.as_ref().is_some_and(|value| {
|
|
!value
|
|
.as_table()
|
|
.is_some_and(|values| values.values().all(|value| value.is_str()))
|
|
}) {
|
|
return Err(format!("MCP 配置字段 {label} 必须是字符串对象"));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn is_protected_mcp_environment_name(name: &str) -> bool {
|
|
matches!(
|
|
name.trim().to_ascii_uppercase().as_str(),
|
|
"GENARRATIVE_AGC_CODEX_API_KEY"
|
|
| "GENARRATIVE_AGC_TOOL_BRIDGE_URL"
|
|
| "AGC_CONTROLLED_WEB_SEARCH_ENABLED"
|
|
| "CODEX_API_KEY"
|
|
)
|
|
}
|
|
|
|
fn mcp_env_vars_field(
|
|
config: &serde_json::Map<String, serde_json::Value>,
|
|
) -> Result<Option<toml::Value>, String> {
|
|
let Some(value) = mcp_config_field(config, &["env_vars", "envVars"]) else {
|
|
return Ok(None);
|
|
};
|
|
let values = value
|
|
.as_array()
|
|
.ok_or_else(|| "MCP 配置字段 env_vars 必须是数组".to_string())?;
|
|
let mut forwarded = Vec::new();
|
|
for value in values {
|
|
let name = value
|
|
.as_str()
|
|
.or_else(|| value.get("name").and_then(serde_json::Value::as_str))
|
|
.filter(|name| !name.trim().is_empty())
|
|
.ok_or_else(|| "MCP 配置字段 env_vars 包含无效条目".to_string())?;
|
|
if value.as_object().is_some_and(|entry| {
|
|
entry
|
|
.get("source")
|
|
.is_some_and(|source| !matches!(source.as_str(), Some("local" | "remote")))
|
|
}) {
|
|
return Err("MCP 配置字段 env_vars 包含无效 source".to_string());
|
|
}
|
|
if is_protected_mcp_environment_name(name) {
|
|
continue;
|
|
}
|
|
forwarded.push(
|
|
toml::Value::try_from(value.clone())
|
|
.map_err(|_| "MCP 配置字段 env_vars 无法转换为原生 TOML".to_string())?,
|
|
);
|
|
}
|
|
Ok((!forwarded.is_empty()).then_some(toml::Value::Array(forwarded)))
|
|
}
|
|
|
|
fn mcp_env_http_headers_field(
|
|
config: &serde_json::Map<String, serde_json::Value>,
|
|
) -> Result<Option<toml::Value>, String> {
|
|
let Some(value) = mcp_config_field(config, &["env_http_headers", "envHttpHeaders"]) else {
|
|
return Ok(None);
|
|
};
|
|
let headers = value
|
|
.as_object()
|
|
.ok_or_else(|| "MCP 配置字段 env_http_headers 必须是字符串对象".to_string())?;
|
|
let mut forwarded = toml::map::Map::new();
|
|
for (header, value) in headers {
|
|
let environment_name = value
|
|
.as_str()
|
|
.ok_or_else(|| "MCP 配置字段 env_http_headers 必须是字符串对象".to_string())?;
|
|
if !is_protected_mcp_environment_name(environment_name) {
|
|
forwarded.insert(
|
|
header.clone(),
|
|
toml::Value::String(environment_name.to_string()),
|
|
);
|
|
}
|
|
}
|
|
Ok((!forwarded.is_empty()).then_some(toml::Value::Table(forwarded)))
|
|
}
|
|
|
|
fn resolve_mcp_cwd(source_directory: &Path, configured: Option<String>) -> PathBuf {
|
|
configured
|
|
.map(PathBuf::from)
|
|
.map(|path| {
|
|
if path.is_absolute() {
|
|
path
|
|
} else {
|
|
source_directory.join(path)
|
|
}
|
|
})
|
|
.unwrap_or_else(|| source_directory.to_path_buf())
|
|
}
|
|
|
|
fn normalize_mcp_runtime_config(
|
|
config: &serde_json::Value,
|
|
source_directory: &Path,
|
|
) -> Result<BTreeMap<String, toml::Value>, String> {
|
|
let config = config
|
|
.as_object()
|
|
.ok_or_else(|| "MCP Server 配置必须是对象".to_string())?;
|
|
let command = mcp_string_field(config, &["command"], "command")?;
|
|
let url = mcp_string_field(config, &["url"], "url")?;
|
|
if command.is_some() == url.is_some() {
|
|
return Err("MCP Server 必须且只能配置 command 或 url".to_string());
|
|
}
|
|
|
|
let mut runtime = BTreeMap::new();
|
|
if let Some(command) = command {
|
|
runtime.insert("command".to_string(), toml::Value::String(command));
|
|
if let Some(args) = mcp_string_array_field(config, &["args"], "args")? {
|
|
runtime.insert("args".to_string(), args);
|
|
}
|
|
if let Some(env) = mcp_string_table_field(config, &["env"], "env")? {
|
|
runtime.insert("env".to_string(), env);
|
|
}
|
|
if let Some(env_vars) = mcp_env_vars_field(config)? {
|
|
runtime.insert("env_vars".to_string(), env_vars);
|
|
}
|
|
let cwd = resolve_mcp_cwd(source_directory, mcp_string_field(config, &["cwd"], "cwd")?);
|
|
runtime.insert(
|
|
"cwd".to_string(),
|
|
toml::Value::String(cwd.to_string_lossy().into_owned()),
|
|
);
|
|
} else if let Some(url) = url {
|
|
runtime.insert("url".to_string(), toml::Value::String(url));
|
|
if let Some(bearer_token_env_var) = mcp_string_field(
|
|
config,
|
|
&["bearer_token_env_var", "bearerTokenEnvVar"],
|
|
"bearer_token_env_var",
|
|
)? {
|
|
if !is_protected_mcp_environment_name(&bearer_token_env_var) {
|
|
runtime.insert(
|
|
"bearer_token_env_var".to_string(),
|
|
toml::Value::String(bearer_token_env_var),
|
|
);
|
|
}
|
|
}
|
|
if let Some(headers) = mcp_string_table_field(
|
|
config,
|
|
&["http_headers", "httpHeaders", "headers"],
|
|
"http_headers",
|
|
)? {
|
|
runtime.insert("http_headers".to_string(), headers);
|
|
}
|
|
if let Some(headers) = mcp_env_http_headers_field(config)? {
|
|
runtime.insert("env_http_headers".to_string(), headers);
|
|
}
|
|
}
|
|
|
|
for (field, aliases) in [
|
|
(
|
|
"startup_timeout_sec",
|
|
&["startup_timeout_sec", "startupTimeoutSec"][..],
|
|
),
|
|
(
|
|
"tool_timeout_sec",
|
|
&["tool_timeout_sec", "toolTimeoutSec"][..],
|
|
),
|
|
("enabled_tools", &["enabled_tools", "enabledTools"][..]),
|
|
("disabled_tools", &["disabled_tools", "disabledTools"][..]),
|
|
] {
|
|
let value = if matches!(field, "enabled_tools" | "disabled_tools") {
|
|
mcp_string_array_field(config, aliases, field)?
|
|
} else {
|
|
let value = mcp_toml_field(config, aliases, field)?;
|
|
if value
|
|
.as_ref()
|
|
.is_some_and(|value| !matches!(value, toml::Value::Integer(value) if *value >= 0))
|
|
{
|
|
return Err(format!("MCP 配置字段 {field} 必须是非负整数"));
|
|
}
|
|
value
|
|
};
|
|
if let Some(value) = value {
|
|
runtime.insert(field.to_string(), value);
|
|
}
|
|
}
|
|
runtime.insert("enabled".to_string(), toml::Value::Boolean(true));
|
|
runtime.insert("required".to_string(), toml::Value::Boolean(false));
|
|
runtime.insert(
|
|
"default_tools_approval_mode".to_string(),
|
|
toml::Value::String("approve".to_string()),
|
|
);
|
|
Ok(runtime)
|
|
}
|
|
|
|
fn rewrite_skill_name(content: &[u8], name: &str) -> Vec<u8> {
|
|
let Ok(text) = std::str::from_utf8(content) else {
|
|
return content.to_vec();
|
|
};
|
|
let mut lines = text.split_inclusive('\n');
|
|
let Some(first) = lines.next() else {
|
|
return content.to_vec();
|
|
};
|
|
if first.trim_matches(['\r', '\n', ' ', '\t']) != "---" {
|
|
return content.to_vec();
|
|
}
|
|
|
|
let newline = if first.ends_with("\r\n") {
|
|
"\r\n"
|
|
} else if first.ends_with('\n') {
|
|
"\n"
|
|
} else {
|
|
"\n"
|
|
};
|
|
let mut output = String::with_capacity(text.len() + name.len() + 16);
|
|
output.push_str(first);
|
|
let mut found_name = false;
|
|
let mut in_frontmatter = true;
|
|
for line in lines {
|
|
let content_end = line.trim_end_matches(['\r', '\n']);
|
|
let line_ending = &line[content_end.len()..];
|
|
if in_frontmatter && content_end.trim() == "---" {
|
|
if !found_name {
|
|
output.push_str("name: ");
|
|
output.push_str(name);
|
|
output.push_str(newline);
|
|
}
|
|
in_frontmatter = false;
|
|
output.push_str(line);
|
|
continue;
|
|
}
|
|
if in_frontmatter {
|
|
let trimmed = content_end.trim_start();
|
|
if trimmed.starts_with("name:") {
|
|
let indent_len = content_end.len() - trimmed.len();
|
|
output.push_str(&content_end[..indent_len]);
|
|
output.push_str("name: ");
|
|
output.push_str(name);
|
|
output.push_str(line_ending);
|
|
found_name = true;
|
|
continue;
|
|
}
|
|
}
|
|
output.push_str(line);
|
|
}
|
|
if in_frontmatter && !found_name {
|
|
output.push_str("name: ");
|
|
output.push_str(name);
|
|
output.push_str(newline);
|
|
}
|
|
output.into_bytes()
|
|
}
|
|
|
|
fn copy_skill_runtime_tree(
|
|
source_directory: &Path,
|
|
skill_file: &Path,
|
|
destination: &Path,
|
|
name: &str,
|
|
) -> Result<(), String> {
|
|
let files = sorted_directory_files(source_directory)?;
|
|
for (relative, path) in files {
|
|
let target = destination.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR));
|
|
if path == skill_file {
|
|
let content = fs::read(&path)
|
|
.map_err(|error| format!("读取客户端 Skill 失败:{}: {error}", path.display()))?;
|
|
if let Some(parent) = target.parent() {
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
format!("创建运行时 Skill 目录失败:{}: {error}", parent.display())
|
|
})?;
|
|
}
|
|
fs::write(&target, rewrite_skill_name(&content, name))
|
|
.map_err(|error| format!("写入运行时 Skill 失败:{}: {error}", target.display()))?;
|
|
} else {
|
|
copy_file(&path, &target)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn prepare_client_skill_runtime_root(
|
|
root: &Path,
|
|
index: &ClientExtensionIndex,
|
|
isolated_os_home: &Path,
|
|
) -> Result<Option<PathBuf>, String> {
|
|
let client_skill_root = isolated_os_home.join(".agents").join("client-skills");
|
|
|
|
// This directory is a complete projection of the currently enabled
|
|
// client Skills. Remove the previous projection before copying so a
|
|
// disabled, deleted, renamed, or otherwise changed Skill cannot remain
|
|
// discoverable through the extra root.
|
|
if client_skill_root.exists() {
|
|
fs::remove_dir_all(&client_skill_root).map_err(|error| {
|
|
format!(
|
|
"清理客户端 Skill 运行时目录失败:{}: {error}",
|
|
client_skill_root.display()
|
|
)
|
|
})?;
|
|
}
|
|
|
|
let mut copied_any = false;
|
|
|
|
for item in index
|
|
.items
|
|
.iter()
|
|
.filter(|item| item.extension_type == "skill" && item.enabled)
|
|
{
|
|
let Some(source) = index
|
|
.sources
|
|
.iter()
|
|
.find(|source| source.id == item.source_id)
|
|
else {
|
|
continue;
|
|
};
|
|
let source_root = root.join(&source.storage_path);
|
|
let source_relative_path = Path::new(&item.source_relative_path);
|
|
let skill_file = source_root.join(source_relative_path);
|
|
if !skill_file.is_file() {
|
|
continue;
|
|
}
|
|
let source_directory = skill_file.parent().unwrap_or(&source_root);
|
|
let destination = client_skill_root.join(&item.name);
|
|
if !copied_any {
|
|
fs::create_dir_all(&client_skill_root).map_err(|error| {
|
|
format!(
|
|
"创建客户端 Skill 运行时目录失败:{}: {error}",
|
|
client_skill_root.display()
|
|
)
|
|
})?;
|
|
copied_any = true;
|
|
}
|
|
copy_skill_runtime_tree(source_directory, &skill_file, &destination, &item.name)?;
|
|
}
|
|
|
|
Ok(copied_any.then_some(client_skill_root))
|
|
}
|
|
|
|
pub(crate) fn prepare_enabled_client_skill_root(
|
|
isolated_os_home: &Path,
|
|
) -> Result<Option<PathBuf>, String> {
|
|
let root = extensions_root()?;
|
|
read_client_extension_index_locked(&root, |root, index| {
|
|
prepare_client_skill_runtime_root(root, index, isolated_os_home)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn prepare_enabled_client_mcp_servers() -> Result<Vec<ClientMcpRuntimeServer>, String> {
|
|
update_client_extension_index(|root, index| {
|
|
let mut servers = Vec::new();
|
|
let mut index_changed = false;
|
|
|
|
for item_index in 0..index.items.len() {
|
|
if index.items[item_index].extension_type != "mcp" || !index.items[item_index].enabled {
|
|
continue;
|
|
}
|
|
let source = index
|
|
.sources
|
|
.iter()
|
|
.find(|source| source.id == index.items[item_index].source_id)
|
|
.cloned();
|
|
let result = (|| {
|
|
if index.items[item_index].name == RESERVED_MCP_SERVER_NAME {
|
|
return Err("MCP Server 名称 agc_tools 为客户端内置保留名称".to_string());
|
|
}
|
|
let source = source.ok_or_else(|| "MCP Server 缺少导入来源".to_string())?;
|
|
let config = index.items[item_index]
|
|
.mcp_config
|
|
.as_ref()
|
|
.ok_or_else(|| "MCP Server 缺少原生配置".to_string())?;
|
|
let source_file = root
|
|
.join(&source.storage_path)
|
|
.join(&index.items[item_index].source_relative_path);
|
|
let source_directory = source_file
|
|
.parent()
|
|
.ok_or_else(|| "MCP Server 来源路径无效".to_string())?;
|
|
normalize_mcp_runtime_config(config, source_directory)
|
|
})();
|
|
match result {
|
|
Ok(config) => {
|
|
if index.items[item_index].last_error.take().is_some() {
|
|
index_changed = true;
|
|
}
|
|
servers.push(ClientMcpRuntimeServer {
|
|
extension_id: index.items[item_index].id.clone(),
|
|
name: index.items[item_index].name.clone(),
|
|
config,
|
|
});
|
|
}
|
|
Err(error) => {
|
|
if index.items[item_index].last_error.as_deref() != Some(error.as_str()) {
|
|
index.items[item_index].last_error = Some(error);
|
|
index_changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok((servers, index_changed))
|
|
})
|
|
}
|
|
|
|
fn apply_client_mcp_startup_status(
|
|
index: &mut ClientExtensionIndex,
|
|
extension_id: &str,
|
|
status: &str,
|
|
) -> Result<bool, String> {
|
|
let Some(item) = index
|
|
.items
|
|
.iter_mut()
|
|
.find(|item| item.extension_type == "mcp" && item.enabled && item.id == extension_id)
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
let last_error = match status {
|
|
"failed" => Some("MCP Server 启动失败".to_string()),
|
|
"cancelled" => Some("MCP Server 启动已取消".to_string()),
|
|
"starting" | "ready" => None,
|
|
_ => return Ok(false),
|
|
};
|
|
if item.last_error == last_error {
|
|
return Ok(false);
|
|
}
|
|
item.last_error = last_error;
|
|
Ok(true)
|
|
}
|
|
|
|
pub(crate) fn record_client_mcp_startup_status(
|
|
extension_id: &str,
|
|
connection_id: &str,
|
|
status: &str,
|
|
) -> Result<(), String> {
|
|
let root = extensions_root()?;
|
|
record_client_mcp_startup_status_at(&root, extension_id, connection_id, status)
|
|
}
|
|
|
|
fn record_client_mcp_startup_status_at(
|
|
root: &Path,
|
|
extension_id: &str,
|
|
connection_id: &str,
|
|
status: &str,
|
|
) -> Result<(), String> {
|
|
update_client_extension_index_at(root, |_, index| {
|
|
let owners = client_mcp_connection_owners()
|
|
.lock()
|
|
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?;
|
|
if owners.get(extension_id).map(String::as_str) != Some(connection_id) {
|
|
return Ok(((), false));
|
|
}
|
|
let changed = apply_client_mcp_startup_status(index, extension_id, status)?;
|
|
Ok(((), changed))
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn pick_client_extension_file(
|
|
app: tauri::AppHandle,
|
|
) -> Result<Option<String>, String> {
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
let mut dialog = app
|
|
.dialog()
|
|
.file()
|
|
.set_title("导入 Skill、MCP 或 Plugin 文件");
|
|
if let Some(window) = app.get_webview_window("client") {
|
|
dialog = dialog.set_parent(&window);
|
|
}
|
|
dialog.pick_file(move |path| {
|
|
let _ = sender.send(path);
|
|
});
|
|
let Some(path) = receiver
|
|
.await
|
|
.map_err(|_| "扩展文件选择器意外关闭".to_string())?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let path = path
|
|
.into_path()
|
|
.map_err(|error| format!("读取扩展文件失败:{error}"))?;
|
|
#[cfg(windows)]
|
|
crate::register_game_creator_user_selected_path(&path, false);
|
|
if let Err(error) =
|
|
crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择扩展文件")
|
|
{
|
|
#[cfg(windows)]
|
|
crate::revoke_game_creator_user_selected_path(&path);
|
|
return Err(error);
|
|
}
|
|
Ok(Some(path.to_string_lossy().into_owned()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn pick_client_extension_directory(
|
|
app: tauri::AppHandle,
|
|
) -> Result<Option<String>, String> {
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
let mut dialog = app
|
|
.dialog()
|
|
.file()
|
|
.set_title("导入 Skill、MCP 或 Plugin 目录");
|
|
if let Some(window) = app.get_webview_window("client") {
|
|
dialog = dialog.set_parent(&window);
|
|
}
|
|
dialog.pick_folder(move |path| {
|
|
let _ = sender.send(path);
|
|
});
|
|
let Some(path) = receiver
|
|
.await
|
|
.map_err(|_| "扩展目录选择器意外关闭".to_string())?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let path = path
|
|
.into_path()
|
|
.map_err(|error| format!("读取扩展目录失败:{error}"))?;
|
|
#[cfg(windows)]
|
|
crate::register_game_creator_user_selected_path(&path, true);
|
|
if let Err(error) =
|
|
crate::prepare_game_creator_user_selected_path_for_read(&path, true, "用户选择扩展目录")
|
|
{
|
|
#[cfg(windows)]
|
|
crate::revoke_game_creator_user_selected_path(&path);
|
|
return Err(error);
|
|
}
|
|
Ok(Some(path.to_string_lossy().into_owned()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn list_client_extensions() -> Result<Vec<ClientExtensionItem>, String> {
|
|
let root = extensions_root()?;
|
|
list_client_extensions_at(&root)
|
|
}
|
|
|
|
fn list_client_extensions_at(root: &Path) -> Result<Vec<ClientExtensionItem>, String> {
|
|
read_client_extension_index_locked(&root, |_, index| {
|
|
Ok(index
|
|
.items
|
|
.iter()
|
|
.map(|item| stored_item_view(index, item))
|
|
.collect())
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn import_client_extension(
|
|
source_path: String,
|
|
) -> Result<ClientExtensionImportResult, String> {
|
|
let source = PathBuf::from(source_path.trim());
|
|
if source.as_os_str().is_empty() {
|
|
return Err("扩展来源不能为空".to_string());
|
|
}
|
|
if !source.is_absolute() {
|
|
return Err("扩展来源必须是绝对路径".to_string());
|
|
}
|
|
let metadata = fs::metadata(&source)
|
|
.map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?;
|
|
if !metadata.is_file() && !metadata.is_dir() {
|
|
return Err("扩展来源必须是普通文件或目录".to_string());
|
|
}
|
|
crate::prepare_game_creator_user_selected_path_for_read(
|
|
&source,
|
|
metadata.is_dir(),
|
|
"客户端扩展来源",
|
|
)?;
|
|
|
|
let root = extensions_root()?;
|
|
import_client_extension_at(&root, &source, &metadata)
|
|
}
|
|
|
|
fn import_client_extension_at(
|
|
root: &Path,
|
|
source: &Path,
|
|
metadata: &fs::Metadata,
|
|
) -> Result<ClientExtensionImportResult, String> {
|
|
let source_id = new_id("source");
|
|
let source_display_name = source_name(&source);
|
|
let source_storage_relative = format!("{}/{}", CLIENT_EXTENSIONS_SOURCES_DIR_NAME, source_id);
|
|
let source_storage = root.join(&source_storage_relative);
|
|
let staging = root.join(format!(".staging-{}", uuid::Uuid::new_v4().simple()));
|
|
let result = (|| {
|
|
let source_fingerprint = prepare_payload(&source, &staging)?;
|
|
let mut candidates = discover_candidates(&staging)?;
|
|
if candidates.is_empty() {
|
|
candidates.push(ImportedCandidate {
|
|
extension_type: "unknown".to_string(),
|
|
original_name: source_display_name.clone(),
|
|
source_relative_path: source_display_name.clone(),
|
|
fingerprint: if metadata.is_file() {
|
|
source_fingerprint.clone()
|
|
} else {
|
|
fingerprint_directory(&staging)?
|
|
},
|
|
mcp_config: None,
|
|
});
|
|
}
|
|
fs::rename(&staging, &source_storage).map_err(|error| {
|
|
format!(
|
|
"安装扩展来源失败:{} -> {}: {error}",
|
|
staging.display(),
|
|
source_storage.display()
|
|
)
|
|
})?;
|
|
let source_record = ImportedExtensionSource {
|
|
id: source_id.clone(),
|
|
original_name: source_display_name.clone(),
|
|
storage_path: source_storage_relative,
|
|
fingerprint: source_fingerprint,
|
|
};
|
|
update_client_extension_index_at(&root, |_, index| {
|
|
let mut used_names: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
for item in &index.items {
|
|
used_names
|
|
.entry(item.extension_type.clone())
|
|
.or_default()
|
|
.insert(item.name.clone());
|
|
}
|
|
used_names
|
|
.entry("mcp".to_string())
|
|
.or_default()
|
|
.insert(RESERVED_MCP_SERVER_NAME.to_string());
|
|
let mut imported = Vec::new();
|
|
let mut renamed = false;
|
|
let mut duplicate = false;
|
|
for candidate in candidates {
|
|
let extension_type = candidate.extension_type;
|
|
let enabled = extension_type != "unknown";
|
|
let duplicate_name = index
|
|
.items
|
|
.iter()
|
|
.find(|item| {
|
|
item.extension_type == extension_type
|
|
&& item.fingerprint == candidate.fingerprint
|
|
})
|
|
.map(|item| item.name.clone());
|
|
duplicate |= duplicate_name.is_some();
|
|
let names = used_names.entry(extension_type.clone()).or_default();
|
|
let requested_name = duplicate_name
|
|
.as_deref()
|
|
.unwrap_or(&candidate.original_name);
|
|
let (name, was_renamed) = if extension_type == "skill" {
|
|
allocate_skill_name(requested_name, names)
|
|
} else {
|
|
allocate_name(requested_name, names)
|
|
};
|
|
renamed |= was_renamed;
|
|
let stored = StoredExtensionItem {
|
|
id: new_id("extension"),
|
|
source_id: source_id.clone(),
|
|
extension_type,
|
|
name,
|
|
original_name: candidate.original_name,
|
|
source_relative_path: candidate.source_relative_path,
|
|
enabled,
|
|
fingerprint: candidate.fingerprint,
|
|
last_error: None,
|
|
mcp_config: candidate.mcp_config,
|
|
};
|
|
index.items.push(stored);
|
|
imported.push(stored_item_view(
|
|
&ClientExtensionIndex {
|
|
schema_version: index.schema_version.clone(),
|
|
sources: index
|
|
.sources
|
|
.iter()
|
|
.cloned()
|
|
.chain(std::iter::once(source_record.clone()))
|
|
.collect(),
|
|
items: index.items.clone(),
|
|
},
|
|
index.items.last().expect("stored extension item"),
|
|
));
|
|
}
|
|
index.sources.push(source_record);
|
|
Ok((
|
|
ClientExtensionImportResult {
|
|
imported,
|
|
source_name: source_display_name,
|
|
renamed,
|
|
duplicate,
|
|
},
|
|
true,
|
|
))
|
|
})
|
|
})();
|
|
let _ = fs::remove_dir_all(&staging);
|
|
result
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn set_client_extension_enabled(
|
|
id: String,
|
|
enabled: bool,
|
|
) -> Result<ClientExtensionItem, String> {
|
|
let root = extensions_root()?;
|
|
set_client_extension_enabled_at(&root, &id, enabled)
|
|
}
|
|
|
|
fn set_client_extension_enabled_at(
|
|
root: &Path,
|
|
id: &str,
|
|
enabled: bool,
|
|
) -> Result<ClientExtensionItem, String> {
|
|
invalidate_client_mcp_connection(id.trim())?;
|
|
update_client_extension_index_at(root, |_, index| {
|
|
let item = index
|
|
.items
|
|
.iter_mut()
|
|
.find(|item| item.id == id.trim())
|
|
.ok_or_else(|| "未找到客户端扩展".to_string())?;
|
|
if item.extension_type == "unknown" && enabled {
|
|
return Err("未识别扩展不能启用".to_string());
|
|
}
|
|
item.enabled = enabled;
|
|
item.last_error = None;
|
|
let view_item = item.clone();
|
|
Ok((stored_item_view(index, &view_item), true))
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn rename_client_extension(
|
|
id: String,
|
|
name: String,
|
|
) -> Result<ClientExtensionItem, String> {
|
|
let requested = name.trim();
|
|
if requested.is_empty() {
|
|
return Err("扩展名称不能为空".to_string());
|
|
}
|
|
let normalized = native_name(requested);
|
|
let root = extensions_root()?;
|
|
rename_client_extension_at(&root, &id, &normalized)
|
|
}
|
|
|
|
fn rename_client_extension_at(
|
|
root: &Path,
|
|
id: &str,
|
|
normalized: &str,
|
|
) -> Result<ClientExtensionItem, String> {
|
|
invalidate_client_mcp_connection(id.trim())?;
|
|
update_client_extension_index_at(root, |_, index| {
|
|
let item_index = index
|
|
.items
|
|
.iter()
|
|
.position(|item| item.id == id.trim())
|
|
.ok_or_else(|| "未找到客户端扩展".to_string())?;
|
|
let extension_type = index.items[item_index].extension_type.clone();
|
|
let conflict = (extension_type == "mcp" && normalized == RESERVED_MCP_SERVER_NAME)
|
|
|| index.items.iter().enumerate().any(|(index, item)| {
|
|
index != item_index
|
|
&& item.extension_type == extension_type
|
|
&& if extension_type == "skill" {
|
|
item.name.eq_ignore_ascii_case(&normalized)
|
|
} else {
|
|
item.name == normalized
|
|
}
|
|
});
|
|
let final_name = if conflict {
|
|
let mut names = index
|
|
.items
|
|
.iter()
|
|
.filter(|item| item.extension_type == extension_type)
|
|
.map(|item| item.name.clone())
|
|
.collect::<BTreeSet<_>>();
|
|
if extension_type == "mcp" {
|
|
names.insert(RESERVED_MCP_SERVER_NAME.to_string());
|
|
}
|
|
if extension_type == "skill" {
|
|
allocate_skill_name(&normalized, &mut names).0
|
|
} else {
|
|
allocate_name(&normalized, &mut names).0
|
|
}
|
|
} else {
|
|
normalized.to_string()
|
|
};
|
|
index.items[item_index].name = final_name;
|
|
let view_item = index.items[item_index].clone();
|
|
Ok((stored_item_view(index, &view_item), true))
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn remove_client_extension(id: String) -> Result<(), String> {
|
|
let root = extensions_root()?;
|
|
remove_client_extension_at(&root, &id)
|
|
}
|
|
|
|
fn remove_client_extension_at(root: &Path, id: &str) -> Result<(), String> {
|
|
invalidate_client_mcp_connection(id.trim())?;
|
|
update_client_extension_index_at(root, |_, index| {
|
|
let item_index = index
|
|
.items
|
|
.iter()
|
|
.position(|item| item.id == id.trim())
|
|
.ok_or_else(|| "未找到客户端扩展".to_string())?;
|
|
index.items.remove(item_index);
|
|
Ok(((), true))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ExpectedImportManifest {
|
|
cases: Vec<ExpectedImportCase>,
|
|
#[serde(rename = "duplicateImport")]
|
|
duplicate_import: ExpectedDuplicateImport,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ExpectedImportCase {
|
|
id: String,
|
|
source: String,
|
|
items: Vec<ExpectedImportItem>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ExpectedImportItem {
|
|
#[serde(rename = "type")]
|
|
extension_type: String,
|
|
name: String,
|
|
#[serde(default)]
|
|
launchable: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ExpectedDuplicateImport {
|
|
source: String,
|
|
items: Vec<ExpectedImportItem>,
|
|
#[serde(rename = "preserveOriginal")]
|
|
preserve_original: bool,
|
|
}
|
|
|
|
fn expected_import_manifest() -> ExpectedImportManifest {
|
|
serde_json::from_str(include_str!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/direct_extensions/expected-imports.json"
|
|
)))
|
|
.expect("parse expected extension imports")
|
|
}
|
|
|
|
fn direct_extensions_fixture_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("fixtures")
|
|
.join("direct_extensions")
|
|
}
|
|
|
|
fn discover_fixture_source(source: &Path) -> Vec<ImportedCandidate> {
|
|
if source.is_dir() {
|
|
return discover_candidates(source).expect("discover fixture directory");
|
|
}
|
|
let staging = tempfile::tempdir().expect("create fixture staging directory");
|
|
fs::copy(
|
|
source,
|
|
staging
|
|
.path()
|
|
.join(source.file_name().expect("fixture file name")),
|
|
)
|
|
.expect("copy fixture file");
|
|
discover_candidates(staging.path()).expect("discover fixture file")
|
|
}
|
|
|
|
fn test_extension_root() -> (tempfile::TempDir, PathBuf) {
|
|
let directory = tempfile::tempdir().expect("temporary extension root");
|
|
let root = directory.path().join(CLIENT_EXTENSIONS_DIR_NAME);
|
|
fs::create_dir_all(root.join(CLIENT_EXTENSIONS_SOURCES_DIR_NAME))
|
|
.expect("create extension sources root");
|
|
write_index(&root, &ClientExtensionIndex::default()).expect("write extension index");
|
|
(directory, root)
|
|
}
|
|
|
|
#[test]
|
|
fn expected_import_manifest_matches_fixture_discovery() {
|
|
let manifest = expected_import_manifest();
|
|
let fixture_root = direct_extensions_fixture_root();
|
|
for case in manifest.cases {
|
|
let source = fixture_root.join(&case.source);
|
|
let candidates = discover_fixture_source(&source);
|
|
let expected = case
|
|
.items
|
|
.iter()
|
|
.map(|item| (item.extension_type.as_str(), item.name.as_str()))
|
|
.collect::<BTreeSet<_>>();
|
|
let actual = candidates
|
|
.iter()
|
|
.map(|candidate| {
|
|
(
|
|
candidate.extension_type.as_str(),
|
|
candidate.original_name.as_str(),
|
|
)
|
|
})
|
|
.collect::<BTreeSet<_>>();
|
|
if case.id == "unknown" {
|
|
assert!(
|
|
candidates.is_empty(),
|
|
"unknown fixture must have no candidates"
|
|
);
|
|
assert_eq!(expected, BTreeSet::from([("unknown", "unknown.bin")]));
|
|
} else {
|
|
assert_eq!(actual, expected, "fixture case {}", case.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn expected_import_manifest_covers_import_lifecycle() {
|
|
let manifest = expected_import_manifest();
|
|
let fixture_root = direct_extensions_fixture_root();
|
|
let single_skill = manifest
|
|
.cases
|
|
.iter()
|
|
.find(|case| case.id == "single-skill")
|
|
.expect("single-skill case");
|
|
let unknown = manifest
|
|
.cases
|
|
.iter()
|
|
.find(|case| case.id == "unknown")
|
|
.expect("unknown case");
|
|
let duplicate = &manifest.duplicate_import;
|
|
assert!(duplicate.preserve_original);
|
|
assert_eq!(duplicate.source, single_skill.source);
|
|
let expected_duplicate = duplicate.items.first().expect("duplicate item");
|
|
|
|
let (_directory, root) = test_extension_root();
|
|
let single_source = fixture_root.join(&single_skill.source);
|
|
let first = import_client_extension_at(
|
|
&root,
|
|
&single_source,
|
|
&fs::metadata(&single_source).expect("single-skill metadata"),
|
|
)
|
|
.expect("import single skill");
|
|
assert_eq!(first.imported.len(), single_skill.items.len());
|
|
assert_eq!(first.imported[0].extension_type, "skill");
|
|
assert_eq!(first.imported[0].name, single_skill.items[0].name);
|
|
assert!(first.imported[0].enabled);
|
|
assert_eq!(first.imported[0].status, "enabled");
|
|
assert!(!first.duplicate);
|
|
assert!(!first.renamed);
|
|
|
|
let second = import_client_extension_at(
|
|
&root,
|
|
&single_source,
|
|
&fs::metadata(&single_source).expect("single-skill metadata"),
|
|
)
|
|
.expect("import duplicate single skill");
|
|
assert!(second.duplicate);
|
|
assert!(second.renamed);
|
|
assert_eq!(second.imported[0].name, expected_duplicate.name);
|
|
|
|
let unknown_source = fixture_root.join(&unknown.source);
|
|
let unknown_result = import_client_extension_at(
|
|
&root,
|
|
&unknown_source,
|
|
&fs::metadata(&unknown_source).expect("unknown metadata"),
|
|
)
|
|
.expect("import unknown fixture");
|
|
let unknown_item = unknown_result.imported.first().expect("unknown item");
|
|
assert_eq!(unknown_item.extension_type, "unknown");
|
|
assert!(!unknown_item.enabled);
|
|
assert_eq!(unknown_item.status, "unknown");
|
|
assert_eq!(unknown.items[0].launchable, Some(false));
|
|
assert!(set_client_extension_enabled_at(&root, &unknown_item.id, true).is_err());
|
|
|
|
let first_id = first.imported[0].id.clone();
|
|
let renamed = rename_client_extension_at(&root, &first_id, "renamed-skill")
|
|
.expect("rename imported skill");
|
|
assert_eq!(renamed.name, "renamed-skill");
|
|
let disabled = set_client_extension_enabled_at(&root, &first_id, false)
|
|
.expect("disable imported skill");
|
|
assert_eq!(disabled.status, "disabled");
|
|
let enabled =
|
|
set_client_extension_enabled_at(&root, &first_id, true).expect("enable imported skill");
|
|
assert_eq!(enabled.status, "enabled");
|
|
remove_client_extension_at(&root, &second.imported[0].id).expect("remove duplicate skill");
|
|
let remaining = list_client_extensions_at(&root).expect("list remaining extensions");
|
|
assert!(remaining.iter().any(|item| item.id == first_id));
|
|
assert!(!remaining
|
|
.iter()
|
|
.any(|item| item.id == second.imported[0].id));
|
|
}
|
|
|
|
#[test]
|
|
fn native_names_use_one_runtime_and_ui_identifier() {
|
|
let mut names = BTreeSet::new();
|
|
assert_eq!(allocate_name("art skill", &mut names).0, "art-skill");
|
|
assert_eq!(allocate_name("art skill", &mut names).0, "art-skill-2");
|
|
assert_eq!(allocate_name("art skill", &mut names).0, "art-skill-3");
|
|
let mut mcp_names = BTreeSet::from([RESERVED_MCP_SERVER_NAME.to_string()]);
|
|
assert_eq!(
|
|
allocate_name(RESERVED_MCP_SERVER_NAME, &mut mcp_names).0,
|
|
"agc_tools-2"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn skill_names_avoid_case_insensitive_runtime_collisions() {
|
|
let mut names = BTreeSet::new();
|
|
assert_eq!(allocate_skill_name("Foo", &mut names).0, "Foo");
|
|
assert_eq!(allocate_skill_name("foo", &mut names).0, "foo-2");
|
|
assert_eq!(allocate_skill_name("FOO", &mut names).0, "FOO-3");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mcp_toml_splits_each_server() {
|
|
let directory = tempfile::tempdir().expect("temp directory");
|
|
let path = directory.path().join("config.toml");
|
|
fs::write(
|
|
&path,
|
|
"[mcp_servers.search]\ncommand = \"search\"\n\n[mcp_servers.filesystem]\ncommand = \"filesystem\"\n",
|
|
)
|
|
.expect("write fixture");
|
|
let servers = parse_mcp_config_file(&path).expect("parse MCP config");
|
|
assert_eq!(servers.len(), 2);
|
|
assert_eq!(servers[0].0, "filesystem");
|
|
assert_eq!(servers[1].0, "search");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mcp_json_splits_each_server() {
|
|
let directory = tempfile::tempdir().expect("temp directory");
|
|
let path = directory.path().join(".mcp.json");
|
|
fs::write(
|
|
&path,
|
|
r#"{"mcpServers":{"search":{"command":"search"},"filesystem":{"command":"filesystem"}}}"#,
|
|
)
|
|
.expect("write fixture");
|
|
let servers = parse_mcp_config_file(&path).expect("parse MCP JSON config");
|
|
assert_eq!(servers.len(), 2);
|
|
assert_eq!(servers[0].0, "filesystem");
|
|
assert_eq!(servers[1].0, "search");
|
|
}
|
|
|
|
#[test]
|
|
fn single_skill_without_frontmatter_uses_stable_fallback_name() {
|
|
let directory = tempfile::tempdir().expect("temp directory");
|
|
let staging = directory.path().join(".staging-test");
|
|
fs::create_dir_all(&staging).expect("create staging directory");
|
|
let path = staging.join("SKILL.md");
|
|
fs::write(&path, "# Skill without frontmatter\n").expect("write skill");
|
|
assert_eq!(parse_skill_name(&path), "skill");
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_skill_copy_rewrites_only_frontmatter_name() {
|
|
let directory = tempfile::tempdir().expect("temp directory");
|
|
let source = directory.path().join("source");
|
|
let destination = directory.path().join("destination");
|
|
fs::create_dir_all(source.join("references")).expect("create source tree");
|
|
let skill_file = source.join("SKILL.md");
|
|
fs::write(
|
|
&skill_file,
|
|
"---\nname: original-skill\ndescription: keep this\n---\n\n# original-skill\n",
|
|
)
|
|
.expect("write source skill");
|
|
fs::write(source.join("references/guide.md"), "reference\n").expect("write reference");
|
|
|
|
copy_skill_runtime_tree(&source, &skill_file, &destination, "renamed-skill")
|
|
.expect("copy runtime skill");
|
|
assert_eq!(
|
|
fs::read_to_string(destination.join("SKILL.md")).expect("read runtime skill"),
|
|
"---\nname: renamed-skill\ndescription: keep this\n---\n\n# original-skill\n"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(destination.join("references/guide.md")).expect("read reference"),
|
|
"reference\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn client_skill_runtime_root_removes_stale_disabled_and_renamed_items() {
|
|
let directory = tempfile::tempdir().expect("temp directory");
|
|
let extension_root = directory.path().join("extensions");
|
|
let source_root = extension_root.join("sources/source-1");
|
|
let skill_a = source_root.join("skill-a/SKILL.md");
|
|
let skill_b = source_root.join("skill-b/SKILL.md");
|
|
fs::create_dir_all(skill_a.parent().expect("skill-a parent")).expect("create skill-a");
|
|
fs::create_dir_all(skill_b.parent().expect("skill-b parent")).expect("create skill-b");
|
|
fs::write(&skill_a, "---\nname: skill-a\n---\n").expect("write skill-a");
|
|
fs::write(&skill_b, "---\nname: skill-b\n---\n").expect("write skill-b");
|
|
|
|
let source = ImportedExtensionSource {
|
|
id: "source-1".to_string(),
|
|
original_name: "fixture".to_string(),
|
|
storage_path: "sources/source-1".to_string(),
|
|
fingerprint: "source-fingerprint".to_string(),
|
|
};
|
|
let mut index = ClientExtensionIndex {
|
|
schema_version: CLIENT_EXTENSIONS_SCHEMA_VERSION.to_string(),
|
|
sources: vec![source],
|
|
items: vec![
|
|
StoredExtensionItem {
|
|
id: "item-a".to_string(),
|
|
source_id: "source-1".to_string(),
|
|
extension_type: "skill".to_string(),
|
|
name: "skill-a".to_string(),
|
|
original_name: "skill-a".to_string(),
|
|
source_relative_path: "skill-a/SKILL.md".to_string(),
|
|
enabled: true,
|
|
fingerprint: "skill-a-fingerprint".to_string(),
|
|
last_error: None,
|
|
mcp_config: None,
|
|
},
|
|
StoredExtensionItem {
|
|
id: "item-b".to_string(),
|
|
source_id: "source-1".to_string(),
|
|
extension_type: "skill".to_string(),
|
|
name: "skill-b".to_string(),
|
|
original_name: "skill-b".to_string(),
|
|
source_relative_path: "skill-b/SKILL.md".to_string(),
|
|
enabled: true,
|
|
fingerprint: "skill-b-fingerprint".to_string(),
|
|
last_error: None,
|
|
mcp_config: None,
|
|
},
|
|
],
|
|
};
|
|
let isolated_home = directory.path().join("home");
|
|
let runtime_root = isolated_home.join(".agents").join("client-skills");
|
|
|
|
prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home)
|
|
.expect("prepare initial Skill runtime root");
|
|
assert!(runtime_root.join("skill-a/SKILL.md").is_file());
|
|
assert!(runtime_root.join("skill-b/SKILL.md").is_file());
|
|
|
|
index.items[0].enabled = false;
|
|
prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home)
|
|
.expect("remove disabled Skill from runtime root");
|
|
assert!(!runtime_root.join("skill-a").exists());
|
|
assert!(runtime_root.join("skill-b/SKILL.md").is_file());
|
|
|
|
index.items[1].name = "renamed-skill".to_string();
|
|
prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home)
|
|
.expect("refresh renamed Skill runtime root");
|
|
assert!(!runtime_root.join("skill-b").exists());
|
|
assert!(runtime_root.join("renamed-skill/SKILL.md").is_file());
|
|
|
|
index.items[1].enabled = false;
|
|
assert_eq!(
|
|
prepare_client_skill_runtime_root(&extension_root, &index, &isolated_home)
|
|
.expect("clear empty Skill runtime root"),
|
|
None
|
|
);
|
|
assert!(!runtime_root.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn client_skill_fingerprint_tracks_enabled_names_and_content() {
|
|
let mut index = ClientExtensionIndex::default();
|
|
index.items.push(StoredExtensionItem {
|
|
id: "skill-1".to_string(),
|
|
source_id: "source-1".to_string(),
|
|
extension_type: "skill".to_string(),
|
|
name: "first-skill".to_string(),
|
|
original_name: "first-skill".to_string(),
|
|
source_relative_path: "first/SKILL.md".to_string(),
|
|
enabled: true,
|
|
fingerprint: "content-a".to_string(),
|
|
last_error: None,
|
|
mcp_config: None,
|
|
});
|
|
let first = client_skill_set_fingerprint(&index);
|
|
index.items[0].enabled = false;
|
|
let disabled = client_skill_set_fingerprint(&index);
|
|
assert_ne!(first, disabled);
|
|
index.items[0].enabled = true;
|
|
index.items[0].name = "renamed-skill".to_string();
|
|
let renamed = client_skill_set_fingerprint(&index);
|
|
assert_ne!(first, renamed);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_mcp_config_preserves_native_transport_fields() {
|
|
let source = Path::new("installed/plugin/mcp");
|
|
let config = serde_json::json!({
|
|
"command": "node",
|
|
"args": ["server.js"],
|
|
"env": {"PLUGIN_TOKEN": "fixture"},
|
|
"env_vars": ["LOCAL_TOKEN", "GENARRATIVE_AGC_CODEX_API_KEY"],
|
|
"startup_timeout_sec": 15,
|
|
"enabled": false,
|
|
"required": true
|
|
});
|
|
let runtime = normalize_mcp_runtime_config(&config, source).expect("normalize stdio MCP");
|
|
assert_eq!(runtime["command"], toml::Value::String("node".to_string()));
|
|
assert_eq!(
|
|
runtime["cwd"],
|
|
toml::Value::String(source.to_string_lossy().into_owned())
|
|
);
|
|
assert_eq!(
|
|
runtime["env_vars"],
|
|
toml::Value::Array(vec![toml::Value::String("LOCAL_TOKEN".to_string())])
|
|
);
|
|
assert_eq!(runtime["enabled"], toml::Value::Boolean(true));
|
|
assert_eq!(runtime["required"], toml::Value::Boolean(false));
|
|
assert_eq!(
|
|
runtime["default_tools_approval_mode"],
|
|
toml::Value::String("approve".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_http_mcp_config_maps_standard_json_aliases() {
|
|
let config = serde_json::json!({
|
|
"type": "http",
|
|
"url": "https://example.invalid/mcp",
|
|
"headers": {"X-Fixture": "value"},
|
|
"envHttpHeaders": {
|
|
"X-Allowed": "LOCAL_TOKEN",
|
|
"Authorization": "GENARRATIVE_AGC_CODEX_API_KEY"
|
|
},
|
|
"bearerTokenEnvVar": "GENARRATIVE_AGC_CODEX_API_KEY",
|
|
"toolTimeoutSec": 45
|
|
});
|
|
let runtime = normalize_mcp_runtime_config(&config, Path::new("installed/plugin"))
|
|
.expect("normalize HTTP MCP");
|
|
assert_eq!(
|
|
runtime["url"],
|
|
toml::Value::String("https://example.invalid/mcp".to_string())
|
|
);
|
|
assert!(runtime.contains_key("http_headers"));
|
|
assert!(!runtime.contains_key("bearer_token_env_var"));
|
|
assert_eq!(
|
|
runtime["env_http_headers"],
|
|
toml::Value::Table(toml::map::Map::from_iter([(
|
|
"X-Allowed".to_string(),
|
|
toml::Value::String("LOCAL_TOKEN".to_string())
|
|
)]))
|
|
);
|
|
assert_eq!(runtime["tool_timeout_sec"], toml::Value::Integer(45));
|
|
}
|
|
|
|
#[test]
|
|
fn client_mcp_fingerprint_tracks_enable_and_rename() {
|
|
let mut index = ClientExtensionIndex::default();
|
|
index.items.push(StoredExtensionItem {
|
|
id: "mcp-1".to_string(),
|
|
source_id: "source-1".to_string(),
|
|
extension_type: "mcp".to_string(),
|
|
name: "search".to_string(),
|
|
original_name: "search".to_string(),
|
|
source_relative_path: "config.toml".to_string(),
|
|
enabled: true,
|
|
fingerprint: "content-a".to_string(),
|
|
last_error: None,
|
|
mcp_config: Some(serde_json::json!({"command": "search"})),
|
|
});
|
|
let first = client_mcp_set_fingerprint(&index);
|
|
index.items[0].enabled = false;
|
|
assert_ne!(first, client_mcp_set_fingerprint(&index));
|
|
index.items[0].enabled = true;
|
|
index.items[0].name = "search-2".to_string();
|
|
assert_ne!(first, client_mcp_set_fingerprint(&index));
|
|
}
|
|
|
|
#[test]
|
|
fn client_mcp_fingerprint_tracks_import_identity() {
|
|
let mut index = ClientExtensionIndex::default();
|
|
index.items.push(StoredExtensionItem {
|
|
id: "mcp-1".to_string(),
|
|
source_id: "source-1".to_string(),
|
|
extension_type: "mcp".to_string(),
|
|
name: "search".to_string(),
|
|
original_name: "search".to_string(),
|
|
source_relative_path: "config.toml".to_string(),
|
|
enabled: true,
|
|
fingerprint: "content-a".to_string(),
|
|
last_error: None,
|
|
mcp_config: Some(serde_json::json!({"command": "search"})),
|
|
});
|
|
let first = client_mcp_set_fingerprint(&index);
|
|
index.items[0].id = "mcp-2".to_string();
|
|
assert_ne!(first, client_mcp_set_fingerprint(&index));
|
|
index.items[0].id = "mcp-1".to_string();
|
|
index.items[0].source_id = "source-2".to_string();
|
|
assert_ne!(first, client_mcp_set_fingerprint(&index));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_startup_status_targets_extension_id() {
|
|
let mut index = ClientExtensionIndex::default();
|
|
for (id, source_id) in [("mcp-old", "source-1"), ("mcp-new", "source-2")] {
|
|
index.items.push(StoredExtensionItem {
|
|
id: id.to_string(),
|
|
source_id: source_id.to_string(),
|
|
extension_type: "mcp".to_string(),
|
|
name: "search".to_string(),
|
|
original_name: "search".to_string(),
|
|
source_relative_path: "config.toml".to_string(),
|
|
enabled: true,
|
|
fingerprint: "content-a".to_string(),
|
|
last_error: None,
|
|
mcp_config: Some(serde_json::json!({"command": "search"})),
|
|
});
|
|
}
|
|
|
|
apply_client_mcp_startup_status(&mut index, "mcp-old", "failed")
|
|
.expect("record old extension startup failure");
|
|
assert_eq!(
|
|
index.items[0].last_error.as_deref(),
|
|
Some("MCP Server 启动失败")
|
|
);
|
|
assert_eq!(index.items[1].last_error, None);
|
|
|
|
apply_client_mcp_startup_status(&mut index, "mcp-old", "ready")
|
|
.expect("clear old extension startup failure");
|
|
assert_eq!(index.items[0].last_error, None);
|
|
assert_eq!(index.items[1].last_error, None);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_mcp_startup_status_from_old_connection_is_ignored() {
|
|
let (_directory, root) = test_extension_root();
|
|
let extension_id = "mcp-stale-status";
|
|
update_client_extension_index_at(&root, |_, index| {
|
|
index.items.push(StoredExtensionItem {
|
|
id: extension_id.to_string(),
|
|
source_id: "source-1".to_string(),
|
|
extension_type: "mcp".to_string(),
|
|
name: "search".to_string(),
|
|
original_name: "search".to_string(),
|
|
source_relative_path: "config.toml".to_string(),
|
|
enabled: true,
|
|
fingerprint: "content-a".to_string(),
|
|
last_error: None,
|
|
mcp_config: Some(serde_json::json!({"command": "search"})),
|
|
});
|
|
Ok(((), true))
|
|
})
|
|
.expect("insert MCP extension");
|
|
|
|
claim_client_mcp_connection_at(&root, [extension_id.to_string()], "connection-old")
|
|
.expect("claim old MCP connection");
|
|
record_client_mcp_startup_status_at(&root, extension_id, "connection-old", "failed")
|
|
.expect("record old failure");
|
|
|
|
claim_client_mcp_connection_at(&root, [extension_id.to_string()], "connection-new")
|
|
.expect("claim new MCP connection");
|
|
record_client_mcp_startup_status_at(&root, extension_id, "connection-old", "ready")
|
|
.expect("ignore stale ready status");
|
|
let index = read_index(&root).expect("read status after stale callback");
|
|
assert_eq!(
|
|
index.items[0].last_error.as_deref(),
|
|
Some("MCP Server 启动失败")
|
|
);
|
|
|
|
record_client_mcp_startup_status_at(&root, extension_id, "connection-new", "ready")
|
|
.expect("record current ready status");
|
|
let index = read_index(&root).expect("read status after current callback");
|
|
assert_eq!(index.items[0].last_error, None);
|
|
release_client_mcp_connection([extension_id.to_string()], "connection-new")
|
|
.expect("release current MCP connection");
|
|
}
|
|
|
|
#[test]
|
|
fn client_extension_index_updates_read_latest_snapshot() {
|
|
let directory = tempfile::tempdir().expect("temporary extension root");
|
|
let root = directory.path().join(CLIENT_EXTENSIONS_DIR_NAME);
|
|
fs::create_dir_all(root.join(CLIENT_EXTENSIONS_SOURCES_DIR_NAME))
|
|
.expect("create extension sources root");
|
|
write_index(&root, &ClientExtensionIndex::default()).expect("write initial index");
|
|
|
|
let left_root = root.clone();
|
|
let left = std::thread::spawn(move || {
|
|
update_client_extension_index_at(&left_root, |_, index| {
|
|
index.items.push(StoredExtensionItem {
|
|
id: "left".to_string(),
|
|
source_id: "source-1".to_string(),
|
|
extension_type: "skill".to_string(),
|
|
name: "left".to_string(),
|
|
original_name: "left".to_string(),
|
|
source_relative_path: "left/SKILL.md".to_string(),
|
|
enabled: true,
|
|
fingerprint: "left".to_string(),
|
|
last_error: None,
|
|
mcp_config: None,
|
|
});
|
|
Ok(((), true))
|
|
})
|
|
});
|
|
let right_root = root.clone();
|
|
let right = std::thread::spawn(move || {
|
|
update_client_extension_index_at(&right_root, |_, index| {
|
|
index.items.push(StoredExtensionItem {
|
|
id: "right".to_string(),
|
|
source_id: "source-2".to_string(),
|
|
extension_type: "mcp".to_string(),
|
|
name: "right".to_string(),
|
|
original_name: "right".to_string(),
|
|
source_relative_path: "config.toml".to_string(),
|
|
enabled: true,
|
|
fingerprint: "right".to_string(),
|
|
last_error: None,
|
|
mcp_config: Some(serde_json::json!({"command": "right"})),
|
|
});
|
|
Ok(((), true))
|
|
})
|
|
});
|
|
left.join().expect("join left update").expect("left update");
|
|
right
|
|
.join()
|
|
.expect("join right update")
|
|
.expect("right update");
|
|
|
|
let index = read_index(&root).expect("read serialized index");
|
|
let ids = index
|
|
.items
|
|
.iter()
|
|
.map(|item| item.id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
assert!(ids.contains("left"));
|
|
assert!(ids.contains("right"));
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_source_discovers_skill_and_mcp_as_independent_items() {
|
|
let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("fixtures")
|
|
.join("direct_extensions")
|
|
.join("plugin");
|
|
let candidates = discover_candidates(&fixture_root).expect("discover plugin fixture");
|
|
let names = candidates
|
|
.iter()
|
|
.map(|candidate| {
|
|
(
|
|
candidate.extension_type.as_str(),
|
|
candidate.original_name.as_str(),
|
|
)
|
|
})
|
|
.collect::<BTreeSet<_>>();
|
|
assert_eq!(candidates.len(), 2);
|
|
assert!(names.contains(&("skill", "plugin-skill")));
|
|
assert!(names.contains(&("mcp", "plugin-search")));
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_directory_splits_independent_skill_and_mcp_items() {
|
|
let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("fixtures")
|
|
.join("direct_extensions")
|
|
.join("mixed-source");
|
|
let candidates = discover_candidates(&fixture_root).expect("discover mixed fixture");
|
|
let names = candidates
|
|
.iter()
|
|
.map(|candidate| {
|
|
(
|
|
candidate.extension_type.as_str(),
|
|
candidate.original_name.as_str(),
|
|
)
|
|
})
|
|
.collect::<BTreeSet<_>>();
|
|
assert!(names.contains(&("skill", "mixed-skill")));
|
|
assert!(names.contains(&("mcp", "filesystem")));
|
|
assert!(names.contains(&("mcp", "search")));
|
|
assert_eq!(candidates.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn zip_path_traversal_is_rejected() {
|
|
let directory = tempfile::tempdir().expect("temp directory");
|
|
let zip_path = directory.path().join("bad.zip");
|
|
let file = fs::File::create(&zip_path).expect("create zip");
|
|
let mut writer = zip::ZipWriter::new(file);
|
|
writer
|
|
.start_file("../outside.txt", zip::write::SimpleFileOptions::default())
|
|
.expect("start entry");
|
|
writer.write_all(b"outside").expect("write entry");
|
|
writer.finish().expect("finish zip");
|
|
let error = extract_zip(&zip_path, &directory.path().join("staging"))
|
|
.expect_err("reject traversal");
|
|
assert!(error.contains("越界"));
|
|
assert!(!directory.path().join("outside.txt").exists());
|
|
}
|
|
}
|