Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs
T
kdletters 485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
AGC 七项交付效率优化并升级捆绑 Codex 到 0.155.1 (#439)
## 背景

净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。

## 结果

- 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。
- 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。
- 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。
- 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。
- 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。
- 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。
- 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。

## 验证

- 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。
- 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。
- 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。
- `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。

## 未覆盖

- 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。
- macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。
- 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
2026-09-21 02:29:29 +08:00

2487 lines
92 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>,
}
pub(crate) struct ClientPluginSource {
pub(crate) item: ClientExtensionItem,
pub(crate) root: PathBuf,
}
#[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" && extension_effectively_enabled(&index, item))
.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()));
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & 0x400 != 0 {
return Err("扩展目录不能包含重解析点".to_string());
}
}
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") || 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());
};
if file_name.eq_ignore_ascii_case("mcp.json") {
if value.get("$schema").and_then(serde_json::Value::as_str)
!= Some("https://agent-plugins.org/schemas/1.0.0/mcp.schema.json")
{
return Err("不支持的 Agent Plugins MCP schema".to_string());
}
if let Some(servers) = value
.get("mcpServers")
.and_then(serde_json::Value::as_object)
{
for config in servers.values() {
match config.get("type").and_then(serde_json::Value::as_str) {
Some("stdio")
if config.get("command").is_some() && config.get("url").is_none() => {}
Some("streamable-http")
if config.get("url").is_some() && config.get("command").is_none() => {}
_ => return Err("Agent Plugins MCP transport 配置无效".to_string()),
}
}
}
}
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();
let has_manifest = |path: &Path| {
path.join("plugin.json").is_file() || path.join(".codex-plugin/plugin.json").is_file()
};
let plugin_root = if has_manifest(payload) {
Some(payload.to_path_buf())
} else {
let roots = fs::read_dir(payload)
.map_err(|_| "扩展来源不可读".to_string())?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir() && has_manifest(path))
.collect::<Vec<_>>();
if roots.len() > 1 {
return Err("请一次导入一个 Plugin 目录".to_string());
}
roots.into_iter().next()
};
let portable = plugin_root
.as_ref()
.is_some_and(|path| path.join("plugin.json").is_file());
let mut prefix = String::new();
if let Some(plugin_root) = plugin_root.as_ref() {
let relative_root = plugin_root
.strip_prefix(payload)
.map_err(|_| "Plugin 来源越界".to_string())?;
if !relative_root.as_os_str().is_empty() {
prefix = format!("{}/", normalize_relative_path(relative_root)?);
}
let manifest = crate::plugin_host::read_plugin_manifest(plugin_root)?;
candidates.push(ImportedCandidate {
extension_type: "plugin".to_string(),
original_name: manifest.id,
source_relative_path: format!(
"{prefix}{}",
if portable {
"plugin.json"
} else {
".codex-plugin/plugin.json"
}
),
fingerprint: fingerprint_directory(plugin_root)?,
mcp_config: None,
});
}
for (relative, path) in files {
if portable
&& !(relative.starts_with(&format!("{prefix}skills/")) && is_skill_file(&path)
|| relative == format!("{prefix}mcp.json"))
{
continue;
}
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 extension_effectively_enabled(index, item) {
"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" && extension_effectively_enabled(index, item))
.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" && extension_effectively_enabled(index, item))
.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(
"supports_parallel_tool_calls".to_string(),
toml::Value::Boolean(true),
);
runtime.insert(
"default_tools_approval_mode".to_string(),
toml::Value::String("prompt".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" && extension_effectively_enabled(index, item))
{
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"
|| !extension_effectively_enabled(index, &index.items[item_index])
{
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 parent_disabled = index
.items
.iter()
.filter(|item| item.extension_type == "plugin" && !item.enabled)
.map(|item| item.source_id.clone())
.collect::<BTreeSet<_>>();
let Some(item) = index.items.iter_mut().find(|item| {
item.extension_type == "mcp"
&& item.enabled
&& !parent_disabled.contains(&item.source_id)
&& 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)
}
pub(crate) 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())
})
}
pub(crate) fn client_plugin_sources_at(root: &Path) -> Result<Vec<ClientPluginSource>, String> {
read_client_extension_index_locked(root, |_, index| {
index
.items
.iter()
.filter(|item| item.extension_type == "plugin")
.map(|item| {
let source = index
.sources
.iter()
.find(|source| source.id == item.source_id)
.ok_or_else(|| "插件来源记录不存在".to_string())?;
normalize_relative_path(Path::new(&source.storage_path))?;
normalize_relative_path(Path::new(&item.source_relative_path))?;
let manifest_path = root
.join(&source.storage_path)
.join(&item.source_relative_path);
let manifest_parent = manifest_path
.parent()
.ok_or_else(|| "插件 manifest 路径无效".to_string())?;
let source_root = if item
.source_relative_path
.ends_with(".codex-plugin/plugin.json")
{
manifest_parent
.parent()
.ok_or_else(|| "插件根路径无效".to_string())?
.to_path_buf()
} else {
manifest_parent.to_path_buf()
};
if !source_root
.canonicalize()
.map_err(|_| "插件来源目录不可读".to_string())?
.starts_with(
root.canonicalize()
.map_err(|_| "扩展目录不可读".to_string())?,
)
{
return Err("插件来源目录越界".to_string());
}
Ok(ClientPluginSource {
item: stored_item_view(index, item),
root: source_root,
})
})
.collect()
})
}
fn extension_effectively_enabled(index: &ClientExtensionIndex, item: &StoredExtensionItem) -> bool {
item.enabled
&& !index.items.iter().any(|parent| {
parent.extension_type == "plugin"
&& parent.source_id == item.source_id
&& !parent.enabled
})
}
#[tauri::command]
pub(crate) fn import_client_extension(
source_path: String,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> 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()?;
let result = import_client_extension_at(&root, &source, &metadata)?;
host.refresh()?;
Ok(result)
}
pub(crate) fn import_client_extension_at(
root: &Path,
source: &Path,
metadata: &fs::Metadata,
) -> Result<ClientExtensionImportResult, String> {
fs::create_dir_all(root.join(CLIENT_EXTENSIONS_SOURCES_DIR_NAME))
.map_err(|error| format!("准备扩展来源目录失败:{error}"))?;
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,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> Result<ClientExtensionItem, String> {
let root = extensions_root()?;
let item = set_client_extension_enabled_at(&root, &id, enabled)?;
host.refresh()?;
Ok(item)
}
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();
if view_item.extension_type == "plugin" {
let child_ids = index
.items
.iter()
.filter(|item| item.source_id == view_item.source_id)
.map(|item| item.id.as_str())
.collect::<BTreeSet<_>>();
client_mcp_connection_owners()
.lock()
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?
.retain(|id, _| !child_ids.contains(id.as_str()));
}
Ok((stored_item_view(index, &view_item), true))
})
}
#[tauri::command]
pub(crate) fn rename_client_extension(
id: String,
name: String,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> Result<ClientExtensionItem, String> {
let requested = name.trim();
if requested.is_empty() {
return Err("扩展名称不能为空".to_string());
}
let normalized = native_name(requested);
let root = extensions_root()?;
let item = rename_client_extension_at(&root, &id, &normalized)?;
host.refresh()?;
Ok(item)
}
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,
host: tauri::State<'_, crate::plugin_host::PluginHost>,
) -> Result<(), String> {
let root = extensions_root()?;
remove_client_extension_at(&root, &id)?;
host.refresh()?;
Ok(())
}
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())?;
let removed = index.items.remove(item_index);
if removed.extension_type == "plugin" {
let child_ids = index
.items
.iter()
.filter(|item| item.source_id == removed.source_id)
.map(|item| item.id.clone())
.collect::<BTreeSet<_>>();
client_mcp_connection_owners()
.lock()
.map_err(|_| "客户端 MCP 连接锁已损坏".to_string())?
.retain(|id, _| !child_ids.contains(id));
index
.items
.retain(|item| item.source_id != removed.source_id);
}
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["supports_parallel_tool_calls"],
toml::Value::Boolean(true)
);
assert_eq!(
runtime["default_tools_approval_mode"],
toml::Value::String("prompt".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_package_and_skill_and_mcp_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(), 3);
assert!(names.contains(&("plugin", "stage-0-fixture-plugin")));
assert!(names.contains(&("skill", "plugin-skill")));
assert!(names.contains(&("mcp", "plugin-search")));
}
#[test]
fn portable_plugin_parent_controls_component_injection_and_removal() {
let source = tempfile::tempdir().expect("plugin source");
fs::create_dir_all(source.path().join("skills/help")).expect("skills");
fs::write(
source.path().join("plugin.json"),
serde_json::json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "portable-tools"
})
.to_string(),
)
.expect("manifest");
fs::write(
source.path().join("skills/help/SKILL.md"),
"---\nname: help\ndescription: Help with the project.\n---\nRead the project.\n",
)
.expect("skill");
fs::write(source.path().join("mcp.json"), serde_json::json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {"docs": {"type":"streamable-http", "url":"http://127.0.0.1:12345/mcp"}}
}).to_string()).expect("MCP config");
let (_directory, root) = test_extension_root();
let imported = import_client_extension_at(
&root,
source.path(),
&fs::metadata(source.path()).expect("metadata"),
)
.expect("import");
assert_eq!(imported.imported.len(), 3);
let parent = imported
.imported
.iter()
.find(|item| item.extension_type == "plugin")
.expect("parent");
let index = read_index(&root).expect("index");
let skill_fingerprint = client_skill_set_fingerprint(&index);
let mcp_fingerprint = client_mcp_set_fingerprint(&index);
set_client_extension_enabled_at(&root, &parent.id, false).expect("disable package");
let disabled = read_index(&root).expect("disabled index");
assert_ne!(client_skill_set_fingerprint(&disabled), skill_fingerprint);
assert_ne!(client_mcp_set_fingerprint(&disabled), mcp_fingerprint);
assert!(disabled
.items
.iter()
.filter(|item| item.extension_type != "plugin")
.all(|item| !extension_effectively_enabled(&disabled, item)));
set_client_extension_enabled_at(&root, &parent.id, true).expect("enable package");
let enabled = read_index(&root).expect("enabled index");
assert_eq!(client_skill_set_fingerprint(&enabled), skill_fingerprint);
remove_client_extension_at(&root, &parent.id).expect("remove package");
assert!(read_index(&root).expect("remaining index").items.is_empty());
}
#[test]
fn portable_mcp_transport_must_match_connection_fields() {
let directory = tempfile::tempdir().expect("config directory");
let path = directory.path().join("mcp.json");
fs::write(
&path,
serde_json::json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {"docs": {"type":"stdio", "url":"https://example.invalid/mcp"}}
})
.to_string(),
)
.expect("MCP config");
assert!(parse_mcp_config_file(&path).is_err());
}
#[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());
}
}