Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs
T
lhk229 b14e42d9bb
Project CI / Repository checks (push) Failing after 1m45s
Project CI / Frontend tests (push) Successful in 4m1s
Project CI / Backend tests (push) Successful in 6m55s
Project CI / Native shell tests (push) Failing after 16m22s
AGC支持安装扩展skill、MCP (#233)
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>
2026-09-01 20:13:48 +08:00

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());
}
}