diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 5976395ef..665fe6e9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1743,6 +1743,7 @@ dependencies = [ "tauri-plugin-opener", "tempfile", "tokio", + "toml 0.8.2", "ts-rs", "ttf-parser", "typed_floats", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index b11c4d379..3b7ab2880 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-opener = "2" tempfile = "3" +toml = "0.8" ttf-parser = "0.25.1" tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } url = "2" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 6d3aa6237..0fbb5a01f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -7,7 +7,7 @@ use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::sync::{mpsc, oneshot, Mutex, Notify}; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -28,6 +28,7 @@ const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000; const DIRECT_PROJECT_IDLE_TIMEOUT_MS: u64 = 15 * 60 * 1_000; const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000; +const DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS: u64 = 120_000; const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); const DIRECT_CODEX_SHELL_ENVIRONMENT_POLICY: &str = "shell_environment_policy.inherit=\"core\""; @@ -526,6 +527,55 @@ fn should_emit_direct_codex_activity( true } +fn is_terminal_client_mcp_startup_status(status: Option<&str>) -> bool { + matches!(status, Some("ready") | Some("failed") | Some("cancelled")) +} + +async fn wait_for_client_mcp_startup_gate( + client_mcp_server_ids_by_name: &HashMap, + client_mcp_startup_statuses: &Mutex>, + client_mcp_startup_notify: &Notify, + grace: std::time::Duration, +) { + let deadline = tokio::time::Instant::now() + grace; + loop { + // Register before reading the shared state. `notify_waiters` does not retain a + // permit for a future created after the notification, so this ordering is part + // of the readiness gate's correctness contract. + let notified = client_mcp_startup_notify.notified(); + tokio::pin!(notified); + + let pending = { + let statuses = client_mcp_startup_statuses.lock().await; + client_mcp_server_ids_by_name.keys().any(|name| { + !is_terminal_client_mcp_startup_status(statuses.get(name).map(String::as_str)) + }) + }; + if !pending { + return; + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + eprintln!( + "agent.direct_codex.client_mcp_startup_wait timed out after {}ms", + grace.as_millis() + ); + return; + } + tokio::select! { + _ = &mut notified => {} + _ = tokio::time::sleep(remaining) => { + eprintln!( + "agent.direct_codex.client_mcp_startup_wait timed out after {}ms", + grace.as_millis() + ); + return; + } + } + } +} + fn game_creator_codex_app_server_idle_timeout_ms( workspace_mode: CodexAppServerWorkspaceMode, request_timeout_ms: u64, @@ -568,9 +618,14 @@ struct CodexAppServerInner { _working_dir: tempfile::TempDir, workspace_path: std::path::PathBuf, workspace_mode: CodexAppServerWorkspaceMode, + client_mcp_server_ids_by_name: HashMap, + client_mcp_connection_id: Option, + client_mcp_startup_statuses: Mutex>, + client_mcp_startup_notify: Notify, + initial_client_mcp_startup_waited: AtomicBool, _provider_proxy: Option, tool_bridge: Option, - _skill_root: Option, + _skill_roots: Option>, } #[derive(Clone)] @@ -615,6 +670,18 @@ fn game_creator_codex_app_server_pool_key( } else { "disabled".to_string() }; + let client_skill_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + crate::client_extensions::enabled_client_skill_fingerprint() + .unwrap_or_else(|_| "invalid-client-skills".to_string()) + } else { + "disabled".to_string() + }; + let client_mcp_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + crate::client_extensions::enabled_client_mcp_fingerprint() + .unwrap_or_else(|_| "invalid-client-mcp".to_string()) + } else { + "disabled".to_string() + }; let stable = serde_json::json!({ "credentialFingerprint": credential_fingerprint, "baseUrl": llm.base_url, @@ -628,6 +695,8 @@ fn game_creator_codex_app_server_pool_key( }, "workspaceMode": workspace_mode.pool_identity(), "skillPackIdentity": skill_pack_identity, + "clientSkillIdentity": client_skill_identity, + "clientMcpIdentity": client_mcp_identity, "controlledWebSearch": llm.web_search_enabled, "directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" }, "providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" }, @@ -1108,17 +1177,20 @@ fn configure_game_creator_codex_app_server_command_for_mode( let controlled_web_search = workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled; command.arg("app-server").arg("--stdio"); - command - .arg("-c") - .arg("mcp_servers={}") - .arg("-c") - .arg("web_search=\"disabled\""); + if workspace_mode != CodexAppServerWorkspaceMode::DirectProject { + command.arg("-c").arg("mcp_servers={}"); + } + command.arg("-c").arg("web_search=\"disabled\""); if workspace_mode != CodexAppServerWorkspaceMode::DirectProject { command.arg("-c").arg("agents.enabled=false"); } if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_executable = direct_tools_mcp_executable_path()?; command + .arg("-c") + .arg(format!( + "mcp_optional_startup_grace_ms={DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS}" + )) .arg("-c") .arg(format!( "mcp_servers.agc_tools.command={}", @@ -1306,14 +1378,41 @@ fn prepare_isolated_game_creator_codex_home( fn trust_isolated_game_creator_codex_workspace( codex_home: &std::path::Path, workspace: &std::path::Path, + client_mcp_servers: &[crate::client_extensions::ClientMcpRuntimeServer], ) -> Result<(), platform_llm::LlmError> { - let workspace = workspace.to_string_lossy(); - let quoted_workspace = quoted_toml_string(&workspace)?; - let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n"); - std::fs::write(codex_home.join("config.toml"), config).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "写入隔离 Codex app-server 项目信任配置失败:{error}" + let mut project = toml::map::Map::new(); + project.insert( + "trust_level".to_string(), + toml::Value::String("trusted".to_string()), + ); + let mut projects = toml::map::Map::new(); + projects.insert( + workspace.to_string_lossy().into_owned(), + toml::Value::Table(project), + ); + let mut root = toml::map::Map::new(); + root.insert("projects".to_string(), toml::Value::Table(projects)); + root.insert( + "mcp_optional_startup_grace_ms".to_string(), + toml::Value::Integer(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64), + ); + if !client_mcp_servers.is_empty() { + let mut mcp_servers = toml::map::Map::new(); + for server in client_mcp_servers { + mcp_servers.insert( + server.name.clone(), + toml::Value::Table(server.config.clone().into_iter().collect()), + ); + } + root.insert("mcp_servers".to_string(), toml::Value::Table(mcp_servers)); + } + let config = toml::to_string(&toml::Value::Table(root)).map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "序列化隔离 Codex app-server 配置失败:{error}" )) + })?; + std::fs::write(codex_home.join("config.toml"), config).map_err(|error| { + platform_llm::LlmError::Transport(format!("写入隔离 Codex app-server 配置失败:{error}")) }) } @@ -1496,8 +1595,25 @@ impl CodexAppServerConnection { .unwrap_or_else(|| isolated_workspace.clone()), ) }; + let client_mcp_servers = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + crate::client_extensions::prepare_enabled_client_mcp_servers() + .map_err(platform_llm::LlmError::InvalidConfig)? + } else { + Vec::new() + }; + let client_mcp_server_ids_by_name = client_mcp_servers + .iter() + .map(|server| (server.name.clone(), server.extension_id.clone())) + .collect::>(); + let client_mcp_connection_id = (!client_mcp_servers.is_empty() + && workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + .then(|| uuid::Uuid::new_v4().simple().to_string()); if workspace_override.is_some() && workspace_mode.allows_workspace_writes() { - trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?; + trust_isolated_game_creator_codex_workspace( + &isolated_codex_home, + &workspace_path, + &client_mcp_servers, + )?; } let isolated_os_home = working_dir.path().join("home"); let isolated_app_data = isolated_os_home.join("appdata"); @@ -1513,10 +1629,18 @@ impl CodexAppServerConnection { )) })?; } - let skill_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let skill_roots = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { install_agc_skill_pack(&isolated_os_home) .map_err(platform_llm::LlmError::InvalidConfig)?; - Some(isolated_os_home.join(".agents").join("skills")) + let bundled_root = isolated_os_home.join(".agents").join("skills"); + let mut roots = vec![bundled_root]; + if let Some(client_root) = + crate::client_extensions::prepare_enabled_client_skill_root(&isolated_os_home) + .map_err(platform_llm::LlmError::InvalidConfig)? + { + roots.push(client_root); + } + Some(roots) } else { None }; @@ -1630,10 +1754,27 @@ impl CodexAppServerConnection { _working_dir: working_dir, workspace_path, workspace_mode, + client_mcp_server_ids_by_name, + client_mcp_connection_id: client_mcp_connection_id.clone(), + client_mcp_startup_statuses: Mutex::new(HashMap::new()), + client_mcp_startup_notify: Notify::new(), + initial_client_mcp_startup_waited: AtomicBool::new(false), _provider_proxy: provider_proxy, tool_bridge, - _skill_root: skill_root, + _skill_roots: skill_roots, }); + if let Some(connection_id) = client_mcp_connection_id.as_deref() { + if let Err(error) = crate::client_extensions::claim_client_mcp_connection( + client_mcp_servers + .iter() + .map(|server| server.extension_id.clone()), + connection_id, + ) { + shutdown_game_creator_codex_app_server_inner(&inner, "客户端 MCP 连接登记失败") + .await; + return Err(platform_llm::LlmError::InvalidConfig(error)); + } + } tokio::spawn(read_game_creator_codex_app_server_stdout( Arc::downgrade(&inner), stdout, @@ -1664,11 +1805,11 @@ impl CodexAppServerConnection { if let Some(reason) = remote_control_disable_reason { eprintln!("agent.codex_app_server.remote_control disabled reason={reason}"); } - if let Some(skill_root) = connection.inner._skill_root.as_ref() { + if let Some(skill_roots) = connection.inner._skill_roots.as_ref() { connection .request( "skills/extraRoots/set", - serde_json::json!({ "extraRoots": [skill_root] }), + serde_json::json!({ "extraRoots": skill_roots }), ) .await .map_err(platform_llm::LlmError::Transport)?; @@ -1706,6 +1847,26 @@ impl CodexAppServerConnection { .map_err(|error| format!("刷新 Codex app-server stdin 失败:{error}")) } + async fn wait_for_initial_client_mcp_startup(&self) { + if self.inner.workspace_mode != CodexAppServerWorkspaceMode::DirectProject + || self.inner.client_mcp_server_ids_by_name.is_empty() + || self + .inner + .initial_client_mcp_startup_waited + .swap(true, Ordering::AcqRel) + { + return; + } + + wait_for_client_mcp_startup_gate( + &self.inner.client_mcp_server_ids_by_name, + &self.inner.client_mcp_startup_statuses, + &self.inner.client_mcp_startup_notify, + std::time::Duration::from_millis(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS), + ) + .await; + } + async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), String> { self.write_message(&serde_json::json!({ "method": method, @@ -1927,6 +2088,7 @@ impl CodexAppServerConnection { ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; + self.wait_for_initial_client_mcp_startup().await; let thread_id = thread_lease.thread_id.clone(); let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) @@ -2510,6 +2672,37 @@ async fn read_game_creator_codex_app_server_stdout( .get("method") .and_then(serde_json::Value::as_str) .unwrap_or_default(); + if method == "mcpServer/startupStatus/updated" + && inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject + { + if let (Some(name), Some(status)) = ( + message + .pointer("/params/name") + .and_then(serde_json::Value::as_str), + message + .pointer("/params/status") + .and_then(serde_json::Value::as_str), + ) { + if inner.client_mcp_server_ids_by_name.contains_key(name) { + inner + .client_mcp_startup_statuses + .lock() + .await + .insert(name.to_string(), status.to_string()); + inner.client_mcp_startup_notify.notify_waiters(); + } + if let Some(extension_id) = inner.client_mcp_server_ids_by_name.get(name) { + if let Some(connection_id) = inner.client_mcp_connection_id.as_deref() { + let _ = crate::client_extensions::record_client_mcp_startup_status( + extension_id, + connection_id, + status, + ); + } + } + } + continue; + } let safe_activity = direct_codex_safe_activity_for_notification(method); if !matches!( method, @@ -2750,6 +2943,7 @@ async fn fail_game_creator_codex_app_server_connection( if inner.closed.swap(true, Ordering::AcqRel) { return; } + release_client_mcp_connection_for_inner(&inner); let exit_status = inner .child .lock() @@ -2776,6 +2970,7 @@ async fn shutdown_game_creator_codex_app_server_inner( reason: &str, ) { inner.closed.store(true, Ordering::Release); + release_client_mcp_connection_for_inner(inner); for (_, pending) in inner.pending.lock().await.drain() { let _ = pending.sender.send(Err(reason.to_string())); } @@ -2789,6 +2984,16 @@ async fn shutdown_game_creator_codex_app_server_inner( } } +fn release_client_mcp_connection_for_inner(inner: &Arc) { + let Some(connection_id) = inner.client_mcp_connection_id.as_deref() else { + return; + }; + let _ = crate::client_extensions::release_client_mcp_connection( + inner.client_mcp_server_ids_by_name.values().cloned(), + connection_id, + ); +} + pub(in crate::agent) async fn request_game_creator_agent_codex_app_server( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -3190,6 +3395,50 @@ mod tests { ); } + #[test] + fn client_mcp_startup_terminal_statuses_are_recognized() { + for status in ["ready", "failed", "cancelled"] { + assert!(is_terminal_client_mcp_startup_status(Some(status))); + } + for status in [None, Some("starting"), Some("unknown")] { + assert!(!is_terminal_client_mcp_startup_status(status)); + } + } + + #[tokio::test] + async fn client_mcp_startup_gate_rechecks_after_status_notification() { + let server_ids_by_name = Arc::new(HashMap::from([( + "search".to_string(), + "extension-search".to_string(), + )])); + let statuses = Arc::new(Mutex::new(HashMap::new())); + let notify = Arc::new(Notify::new()); + let wait_server_ids_by_name = Arc::clone(&server_ids_by_name); + let wait_statuses = Arc::clone(&statuses); + let wait_notify = Arc::clone(¬ify); + let waiter = tokio::spawn(async move { + wait_for_client_mcp_startup_gate( + &wait_server_ids_by_name, + &wait_statuses, + &wait_notify, + std::time::Duration::from_millis(250), + ) + .await; + }); + + tokio::task::yield_now().await; + statuses + .lock() + .await + .insert("search".to_string(), "ready".to_string()); + notify.notify_waiters(); + + tokio::time::timeout(std::time::Duration::from_millis(100), waiter) + .await + .expect("startup gate should wake after terminal status") + .expect("startup gate task should not panic"); + } + #[test] fn direct_home_mode_is_read_only_and_rejects_non_passive_items() { assert!(CodexAppServerWorkspaceMode::DirectHome.uses_direct_conversation()); @@ -3690,6 +3939,46 @@ mod tests { assert!(joined.contains("mcp_servers.agc_tools.command=")); assert!(joined.contains(DIRECT_TOOLS_MCP_MODE_FLAG)); assert!(joined.contains("mcp_servers.agc_tools.required=true")); + assert!(joined.contains("mcp_optional_startup_grace_ms=120000")); + assert!(!joined.contains("mcp_servers={}")); + } + + #[test] + fn isolated_direct_project_config_contains_client_mcp_without_agc_tools() { + let directory = tempfile::tempdir().expect("temporary config home"); + let workspace = directory.path().join("workspace"); + std::fs::create_dir(&workspace).expect("create workspace"); + let client_mcp = crate::client_extensions::ClientMcpRuntimeServer { + extension_id: "extension-1".to_string(), + name: "plugin-search".to_string(), + config: std::collections::BTreeMap::from([ + ( + "command".to_string(), + toml::Value::String("fixture-search".to_string()), + ), + ("required".to_string(), toml::Value::Boolean(false)), + ]), + }; + trust_isolated_game_creator_codex_workspace(directory.path(), &workspace, &[client_mcp]) + .expect("write isolated config"); + let config = std::fs::read_to_string(directory.path().join("config.toml")) + .expect("read isolated config"); + let parsed = config + .parse::() + .expect("parse isolated config"); + assert_eq!( + parsed["mcp_servers"]["plugin-search"]["command"].as_str(), + Some("fixture-search") + ); + assert_eq!( + parsed["mcp_servers"]["plugin-search"]["required"].as_bool(), + Some(false) + ); + assert_eq!( + parsed["mcp_optional_startup_grace_ms"].as_integer(), + Some(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64) + ); + assert!(parsed["mcp_servers"].get("agc_tools").is_none()); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 68c0234c2..f81a8ec89 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -4362,6 +4362,8 @@ mod tests { assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)")); + assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP")); + assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时")); assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`")); assert!(prompt.contains("agc_write_file")); assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs new file mode 100644 index 000000000..5cf4c6f12 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -0,0 +1,2212 @@ +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> = OnceLock::new(); +static CLIENT_MCP_CONNECTION_OWNERS: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ClientMcpRuntimeServer { + pub(crate) extension_id: String, + pub(crate) name: String, + pub(crate) config: BTreeMap, +} + +#[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, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientExtensionImportResult { + pub(crate) imported: Vec, + 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, + items: Vec, +} + +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, + #[serde(default)] + mcp_config: Option, +} + +struct ImportedCandidate { + extension_type: String, + original_name: String, + source_relative_path: String, + fingerprint: String, + mcp_config: Option, +} + +fn extensions_root() -> Result { + 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 { + 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::(&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> { + CLIENT_MCP_CONNECTION_OWNERS.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(crate) fn claim_client_mcp_connection( + extension_ids: impl IntoIterator, + 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, + 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::>(); + 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, + 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( + root: &Path, + reader: impl FnOnce(&Path, &ClientExtensionIndex) -> Result, +) -> Result { + 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( + root: &Path, + updater: impl FnOnce(&Path, &mut ClientExtensionIndex) -> Result<(T, bool), String>, +) -> Result { + 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( + updater: impl FnOnce(&Path, &mut ClientExtensionIndex) -> Result<(T, bool), String>, +) -> Result { + 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 { + let bytes = + fs::read(path).map_err(|error| format!("读取扩展文件失败:{}: {error}", path.display()))?; + Ok(sha256_bytes(&bytes)) +} + +fn normalize_relative_path(path: &Path) -> Result { + 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, 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::, _>>() + .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 { + 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, bool) { + allocate_name_with_case_mode(original_name, existing, false) +} + +fn allocate_skill_name(original_name: &str, existing: &mut BTreeSet) -> (String, bool) { + allocate_name_with_case_mode(original_name, existing, true) +} + +fn allocate_name_with_case_mode( + original_name: &str, + existing: &mut BTreeSet, + 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, 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::(&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::() 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, 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 { + 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::>(); + 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::>(); + 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 { + 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 { + 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, + names: &[&str], +) -> Option<&'a serde_json::Value> { + names.iter().find_map(|name| config.get(*name)) +} + +fn mcp_string_field( + config: &serde_json::Map, + names: &[&str], + label: &str, +) -> Result, 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, + names: &[&str], + label: &str, +) -> Result, 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, + names: &[&str], + label: &str, +) -> Result, 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, + names: &[&str], + label: &str, +) -> Result, 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, +) -> Result, 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, +) -> Result, 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) -> 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, 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 { + 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, 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, 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, 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 { + 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, 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, 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, String> { + let root = extensions_root()?; + list_client_extensions_at(&root) +} + +fn list_client_extensions_at(root: &Path) -> Result, 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 { + 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 { + 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> = 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 { + 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 { + 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 { + 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 { + 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::>(); + 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, + #[serde(rename = "duplicateImport")] + duplicate_import: ExpectedDuplicateImport, + } + + #[derive(Debug, Deserialize)] + struct ExpectedImportCase { + id: String, + source: String, + items: Vec, + } + + #[derive(Debug, Deserialize)] + struct ExpectedImportItem { + #[serde(rename = "type")] + extension_type: String, + name: String, + #[serde(default)] + launchable: Option, + } + + #[derive(Debug, Deserialize)] + struct ExpectedDuplicateImport { + source: String, + items: Vec, + #[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 { + 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::>(); + let actual = candidates + .iter() + .map(|candidate| { + ( + candidate.extension_type.as_str(), + candidate.original_name.as_str(), + ) + }) + .collect::>(); + 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::>(); + 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::>(); + 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::>(); + 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()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 677035b87..f46db50f9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -230,6 +230,7 @@ mod agent_native_tools; mod assets; mod browser; mod cli; +mod client_extensions; mod collaboration; mod command_exec; mod command_output; @@ -268,6 +269,7 @@ use agent_native_tools::*; use assets::*; use browser::*; use cli::*; +use client_extensions::*; use collaboration::*; use command_exec::*; use command_output::*; @@ -2404,6 +2406,13 @@ fn main() { inspect_local_project_directory, pick_local_project_directory, pick_local_file, + pick_client_extension_file, + pick_client_extension_directory, + list_client_extensions, + import_client_extension, + set_client_extension_enabled, + rename_client_extension, + remove_client_extension, open_local_project_directory, open_local_project_plan_gdd_markdown, control_agent_run, diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md new file mode 100644 index 000000000..957325e45 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md @@ -0,0 +1,26 @@ +# DirectProject 扩展导入阶段 0 样例 + +本目录只保存导入分类和命名契约使用的最小样例,不作为运行时扩展安装目录。阶段 0 不启动其中的 MCP,也不执行 Skill 中的脚本。 + +## 样例与预期 + +| 样例 | 预期拆分 | 备注 | +| --- | --- | --- | +| `single-skill/` | 1 个 Skill:`single-skill` | 根目录 `SKILL.md` | +| `multi-skill/` | 2 个 Skill:`art-skill`、`code-skill` | 一个 Skill root,分别导入 | +| `mcp-config/` | 2 个 MCP:`search`、`filesystem` | 原生 `config.toml` | +| `mcp-json/` | 2 个 MCP:`search`、`filesystem` | 兼容 `.mcp.json` | +| `plugin/` | 1 个 Skill:`plugin-skill`、1 个 MCP:`plugin-search` | Plugin 只作为导入来源 | +| `mixed-source/` | 1 个 Skill:`mixed-skill`、2 个 MCP:`search`、`filesystem` | 一个来源拆成多个独立项 | +| `unknown/` | 1 个未知项:`unknown.bin` | 不执行、不作为 MCP 入口 | + +`mixed-source/` 表示目录或 zip 解压后的标准内容。实际 zip 测试可以在测试运行时将该目录压缩为临时归档,不把生成的二进制 zip 提交到仓库。 + +## 阶段 0 固定规则 + +- 一个来源里的每个 Skill 和每个 MCP Server 都是独立扩展项。 +- 导入后已识别项默认启用;未知项保留但不可启动。 +- 同名或重复内容再次导入时保留新项,名称使用原生标识追加 `-2`、`-3`。 +- 前端列表名称和 Codex 运行时名称相同,不维护两套名称。 +- 单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。 +- Plugin 只提取支持的 Skill/MCP,不开启 hooks、apps 或完整 Plugin Runtime。 diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json new file mode 100644 index 000000000..698207aff --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": "direct-project-extension-fixture.v1", + "cases": [ + { + "id": "single-skill", + "source": "single-skill", + "items": [ + { "type": "skill", "name": "single-skill" } + ] + }, + { + "id": "multi-skill", + "source": "multi-skill", + "items": [ + { "type": "skill", "name": "art-skill" }, + { "type": "skill", "name": "code-skill" } + ] + }, + { + "id": "mcp-config", + "source": "mcp-config/config.toml", + "items": [ + { "type": "mcp", "name": "search" }, + { "type": "mcp", "name": "filesystem" } + ] + }, + { + "id": "mcp-json", + "source": "mcp-json/.mcp.json", + "items": [ + { "type": "mcp", "name": "search" }, + { "type": "mcp", "name": "filesystem" } + ] + }, + { + "id": "plugin", + "source": "plugin", + "items": [ + { "type": "skill", "name": "plugin-skill" }, + { "type": "mcp", "name": "plugin-search" } + ], + "ignoredCapabilities": ["hooks", "apps", "remote_plugin"] + }, + { + "id": "mixed-source", + "source": "mixed-source", + "items": [ + { "type": "skill", "name": "mixed-skill" }, + { "type": "mcp", "name": "search" }, + { "type": "mcp", "name": "filesystem" } + ] + }, + { + "id": "unknown", + "source": "unknown/unknown.bin", + "items": [ + { "type": "unknown", "name": "unknown.bin", "launchable": false } + ] + } + ], + "duplicateImport": { + "source": "single-skill", + "items": [ + { "type": "skill", "name": "single-skill-2" } + ], + "preserveOriginal": true + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mcp-config/config.toml b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mcp-config/config.toml new file mode 100644 index 000000000..3ab5542bf --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mcp-config/config.toml @@ -0,0 +1,5 @@ +[mcp_servers.search] +command = "fixture-search" + +[mcp_servers.filesystem] +command = "fixture-filesystem" diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mcp-json/.mcp.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mcp-json/.mcp.json new file mode 100644 index 000000000..1b638569c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mcp-json/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "search": { + "command": "fixture-search" + }, + "filesystem": { + "command": "fixture-filesystem" + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mixed-source/mcp/config.toml b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mixed-source/mcp/config.toml new file mode 100644 index 000000000..3ab5542bf --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mixed-source/mcp/config.toml @@ -0,0 +1,5 @@ +[mcp_servers.search] +command = "fixture-search" + +[mcp_servers.filesystem] +command = "fixture-filesystem" diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mixed-source/skills/mixed-skill/SKILL.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mixed-source/skills/mixed-skill/SKILL.md new file mode 100644 index 000000000..6ade141c3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/mixed-source/skills/mixed-skill/SKILL.md @@ -0,0 +1,8 @@ +--- +name: mixed-skill +description: Stage 0 fixture for a source containing independent Skill and MCP entries. +--- + +# Mixed Source Skill Fixture + +This fixture is used only to verify independent import items. diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/multi-skill/skills/art-skill/SKILL.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/multi-skill/skills/art-skill/SKILL.md new file mode 100644 index 000000000..9bf137047 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/multi-skill/skills/art-skill/SKILL.md @@ -0,0 +1,8 @@ +--- +name: art-skill +description: Stage 0 fixture for an imported art Skill. +--- + +# Art Skill Fixture + +This fixture is used only to verify that one Skill root becomes independent items. diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/multi-skill/skills/code-skill/SKILL.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/multi-skill/skills/code-skill/SKILL.md new file mode 100644 index 000000000..76ac45358 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/multi-skill/skills/code-skill/SKILL.md @@ -0,0 +1,8 @@ +--- +name: code-skill +description: Stage 0 fixture for an imported code Skill. +--- + +# Code Skill Fixture + +This fixture is used only to verify that one Skill root becomes independent items. diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/.codex-plugin/plugin.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/.codex-plugin/plugin.json new file mode 100644 index 000000000..cddf17197 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "stage-0-fixture-plugin", + "version": "0.1.0", + "description": "Fixture for extracting standard Skill content from a Plugin source.", + "skills": "./skills/" +} diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/mcp/.mcp.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/mcp/.mcp.json new file mode 100644 index 000000000..24f528bab --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/mcp/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "plugin-search": { + "type": "stdio", + "command": "fixture-plugin-search" + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/skills/plugin-skill/SKILL.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/skills/plugin-skill/SKILL.md new file mode 100644 index 000000000..50c6d19bd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/skills/plugin-skill/SKILL.md @@ -0,0 +1,8 @@ +--- +name: plugin-skill +description: Stage 0 fixture for a Skill contained in a Plugin. +--- + +# Plugin Skill Fixture + +This fixture is used only to verify Plugin source extraction. diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/single-skill/SKILL.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/single-skill/SKILL.md new file mode 100644 index 000000000..6b4b717e2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/single-skill/SKILL.md @@ -0,0 +1,8 @@ +--- +name: single-skill +description: Stage 0 fixture for a single imported Skill. +--- + +# Single Skill Fixture + +This fixture is used only to verify discovery and naming. diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/unknown/unknown.bin b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/unknown/unknown.bin new file mode 100644 index 000000000..c922f5e9c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/unknown/unknown.bin @@ -0,0 +1 @@ +This is an inert unknown-file fixture. It must never be executed as an MCP server. diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index aaf922b5d..12b177d3a 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -51,6 +51,27 @@ export type LauncherImportedAttachment = { size?: number; }; +export type ClientExtensionType = 'skill' | 'mcp' | 'unknown'; + +export type ClientExtensionItem = { + id: string; + name: string; + originalName: string; + extensionType: ClientExtensionType; + sourceName: string; + sourceRelativePath: string; + enabled: boolean; + status: 'enabled' | 'disabled' | 'unknown' | 'startup-failed'; + lastError: string | null; +}; + +export type ClientExtensionImportResult = { + imported: ClientExtensionItem[]; + sourceName: string; + renamed: boolean; + duplicate: boolean; +}; + export type LocalProjectKind = 'web' | 'godot'; export type ProjectStartMode = 'planning' | 'direct-build'; diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 4d1a1b421..0ff7f445c 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -5,10 +5,13 @@ import { CircleAlert, Info, LoaderCircle, + Pencil, RotateCcw, Save, Settings2, SlidersHorizontal, + Trash2, + Upload, X, } from 'lucide-react'; import { type FormEvent, useEffect, useRef, useState } from 'react'; @@ -23,6 +26,8 @@ import { } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; import { + type ClientExtensionImportResult, + type ClientExtensionItem, type GameCreatorAgentLlmConfig, type GameCreatorAppConfig, type GameCreatorAppConfigView, @@ -87,6 +92,7 @@ type RuntimeSettingsSection = | 'general' | 'agents' | 'connections' + | 'extensions' | 'advanced' | 'about'; @@ -95,6 +101,8 @@ type RuntimeConfigToast = { message: string; }; +type ClientExtensionsLoadState = 'loading' | 'ready' | 'error'; + const runtimeSettingsSections = [ { id: 'general', @@ -114,6 +122,12 @@ const runtimeSettingsSections = [ description: '外部服务', icon: Cable, }, + { + id: 'extensions', + label: '扩展', + description: 'Skill 与 MCP', + icon: Upload, + }, { id: 'advanced', label: '高级参数', @@ -384,6 +398,17 @@ export function RuntimeConfigDialog({ const [activeSection, setActiveSection] = useState('general'); const [expandedAgentIds, setExpandedAgentIds] = useState([]); + const [clientExtensions, setClientExtensions] = useState< + ClientExtensionItem[] + >([]); + const [clientExtensionsBusy, setClientExtensionsBusy] = useState(false); + const [clientExtensionsLoadState, setClientExtensionsLoadState] = + useState('loading'); + const [clientExtensionsStatus, setClientExtensionsStatus] = useState(''); + const [editingExtensionId, setEditingExtensionId] = useState( + null, + ); + const [editingExtensionName, setEditingExtensionName] = useState(''); const [appUpdateStatus, setAppUpdateStatus] = useState(''); const [appUpdateChecking, setAppUpdateChecking] = useState(false); const runtimeConfigBusyRef = useRef(false); @@ -404,10 +429,176 @@ export function RuntimeConfigDialog({ useEffect(() => { void readRuntimeConfig(); + void readClientExtensions(); // The dialog reads once on mount; subsequent reads are explicit user actions. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + async function readClientExtensions() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setClientExtensionsLoadState('error'); + setClientExtensionsStatus('需要在 Tauri App 内运行'); + return; + } + setClientExtensionsLoadState('loading'); + setClientExtensionsBusy(true); + try { + const result = await invoke( + 'list_client_extensions', + ); + setClientExtensions(result); + setClientExtensionsLoadState('ready'); + setClientExtensionsStatus(''); + } catch (error) { + setClientExtensionsLoadState('error'); + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + async function importClientExtension(kind: 'file' | 'directory') { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setClientExtensionsStatus('需要在 Tauri App 内运行'); + return; + } + if (clientExtensionsBusy) { + return; + } + setClientExtensionsBusy(true); + setClientExtensionsStatus('正在导入'); + try { + const selectedPath = + kind === 'file' + ? await invoke('pick_client_extension_file') + : await invoke('pick_client_extension_directory'); + if (!selectedPath) { + setClientExtensionsStatus(''); + return; + } + const result = await invoke( + 'import_client_extension', + { sourcePath: selectedPath }, + ); + const refreshed = await invoke( + 'list_client_extensions', + ); + setClientExtensions(refreshed); + setClientExtensionsLoadState('ready'); + const importedCount = result.imported.length; + setClientExtensionsStatus( + importedCount > 0 + ? `已导入 ${importedCount} 个扩展${result.renamed ? ',同名项已自动追加编号' : ''}` + : '未发现可管理的扩展', + ); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + function beginRenameClientExtension(item: ClientExtensionItem) { + setEditingExtensionId(item.id); + setEditingExtensionName(item.name); + setClientExtensionsStatus(''); + } + + function cancelRenameClientExtension() { + setEditingExtensionId(null); + setEditingExtensionName(''); + } + + async function saveClientExtensionName(item: ClientExtensionItem) { + const invoke = resolveTauriInvoke(); + if (!invoke || clientExtensionsBusy) { + return; + } + setClientExtensionsBusy(true); + try { + const updated = await invoke( + 'rename_client_extension', + { id: item.id, name: editingExtensionName }, + ); + setClientExtensions((current) => + current.map((candidate) => + candidate.id === updated.id ? updated : candidate, + ), + ); + const requestedName = editingExtensionName.trim(); + setClientExtensionsStatus( + updated.name === requestedName + ? '扩展名称已更新' + : `扩展名称已调整为“${updated.name}”`, + ); + cancelRenameClientExtension(); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + async function setClientExtensionEnabled( + item: ClientExtensionItem, + enabled: boolean, + ) { + const invoke = resolveTauriInvoke(); + if (!invoke || clientExtensionsBusy || item.extensionType === 'unknown') { + return; + } + setClientExtensionsBusy(true); + try { + const updated = await invoke( + 'set_client_extension_enabled', + { id: item.id, enabled }, + ); + setClientExtensions((current) => + current.map((candidate) => + candidate.id === updated.id ? updated : candidate, + ), + ); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + async function removeClientExtension(item: ClientExtensionItem) { + const invoke = resolveTauriInvoke(); + if (!invoke || clientExtensionsBusy) { + return; + } + setClientExtensionsBusy(true); + try { + await invoke('remove_client_extension', { id: item.id }); + setClientExtensions((current) => + current.filter((candidate) => candidate.id !== item.id), + ); + if (editingExtensionId === item.id) { + cancelRenameClientExtension(); + } + setClientExtensionsStatus('扩展已移除'); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + function updateRuntimeLlmConfig( key: K, value: GameCreatorAppConfig['llm'][K], @@ -711,6 +902,25 @@ export function RuntimeConfigDialog({ {activeSection === 'agents' ? ( {configuredAgentCount} 个角色已覆盖 + ) : activeSection === 'extensions' ? ( +
+ + +
) : null}
@@ -1293,6 +1503,159 @@ export function RuntimeConfigDialog({ ) : null} ) : null} + {activeSection === 'extensions' ? ( +
+ {clientExtensionsStatus && + clientExtensionsLoadState !== 'error' ? ( +

+ {clientExtensionsStatus} +

+ ) : null} + {clientExtensionsLoadState === 'loading' ? ( +
+ 正在加载扩展 + 正在读取已导入的 Skill 和 MCP。 +
+ ) : clientExtensionsLoadState === 'error' ? ( +
+ 扩展列表加载失败 + + {clientExtensionsStatus || '暂时无法读取扩展列表。'} + +
+ ) : clientExtensions.length > 0 ? ( +
+ {clientExtensions.map((item) => { + const editing = editingExtensionId === item.id; + const typeLabel = + item.extensionType === 'skill' + ? 'Skill' + : item.extensionType === 'mcp' + ? 'MCP' + : '未识别'; + const statusLabel = + item.status === 'enabled' + ? '已启用' + : item.status === 'disabled' + ? '已禁用' + : item.status === 'startup-failed' + ? '启动失败' + : '当前不可用'; + return ( +
+
+ {editing ? ( + + setEditingExtensionName( + event.currentTarget.value, + ) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + void saveClientExtensionName(item); + } else if (event.key === 'Escape') { + event.stopPropagation(); + cancelRenameClientExtension(); + } + }} + /> + ) : ( + {item.name} + )} + + {typeLabel} · 来自 {item.sourceName} + + {item.lastError ? ( + + {item.lastError} + + ) : null} +
+
+ {statusLabel} + {editing ? ( + <> + + + + ) : ( + + )} + {item.extensionType === 'unknown' ? null : ( + + )} + +
+
+ ); + })} +
+ ) : ( +
+ 还没有导入扩展 + + 导入 Skill、MCP 配置或标准 Plugin 后会显示在这里。 + +
+ )} +
+ ) : null} {activeSection === 'about' ? (
span, +.runtime-settings-extension-main > small, +.runtime-settings-extension-actions > span { + overflow: hidden; + color: var(--platform-text-soft); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-settings-extension-main > small { + color: var(--platform-danger, #b45309); +} + +.runtime-settings-extension-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-shrink: 0; +} + +.runtime-settings-extension-actions > span { + margin-right: 2px; +} + +.runtime-settings-empty-state { + display: grid; + gap: 4px; + padding: 24px 14px; + border: 1px dashed var(--platform-subpanel-border); + border-radius: 12px; + color: var(--platform-text-soft); + text-align: center; +} + +.runtime-settings-empty-state strong { + color: var(--platform-text-base); + font-size: 13px; +} + +.runtime-settings-empty-state span { + font-size: 11px; +} + .runtime-settings-fields { align-content: start; } @@ -4669,6 +4799,24 @@ iframe.preview-frame { padding: 18px 14px 22px; } + .runtime-settings-section-header { + align-items: flex-start; + flex-direction: column; + } + + .runtime-settings-section-actions { + justify-content: flex-start; + } + + .runtime-settings-extension-item { + align-items: stretch; + flex-direction: column; + } + + .runtime-settings-extension-actions { + justify-content: flex-start; + } + .runtime-agent-card-fields { grid-template-columns: 1fr; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index e67edd306..8ba0967cd 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -255,6 +255,59 @@ export function registerAgentStatusDerivationTests() { } export function registerRuntimeSettingsTests() { + it('distinguishes loading the client extension list from an empty list', async () => { + let resolveExtensions!: (items: unknown[]) => void; + const extensionsRead = new Promise((resolve) => { + resolveExtensions = resolve; + }); + const invoke = vi.fn((command: string) => { + if (command === 'read_game_creator_app_config') { + return Promise.resolve({ + path: '/home/test/AppData/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-settings-extensions', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: false, + webSearchEnabled: false, + requestTimeoutMs: 180000, + maxRetries: 0, + retryBackoffMs: 500, + }, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: '', + }, + }, + }); + } + if (command === 'list_client_extensions') { + return extensionsRead; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + await screen.findByRole('dialog', { name: '运行时配置' }); + fireEvent.click(screen.getByRole('button', { name: /^扩展/ })); + + expect(await screen.findByText('正在加载扩展')).not.toBeNull(); + expect(screen.queryByText('还没有导入扩展')).toBeNull(); + + await act(async () => { + resolveExtensions([]); + }); + + expect(await screen.findByText('还没有导入扩展')).not.toBeNull(); + expect(screen.queryByText('正在加载扩展')).toBeNull(); + }); + it('shows the client version on the About settings page', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_game_creator_app_config') { diff --git a/docs/README.md b/docs/README.md index 50ee0717b..617391289 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ ## AI 游戏创作与 Agent Runtime - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d0f9ba1b9..d515bca47 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,18 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入 + +- 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。 +- 决策:客户端提供一个全局“扩展”入口,统一接受文件、目录、zip 和标准 Plugin;目录、zip、Plugin 只是导入来源,发现出的每个 Skill 和每个 MCP Server 分别成为独立扩展项,分别列表、重命名、启用、禁用和删除。已识别项导入后默认启用,下次 DirectProject Codex 启动时按原生 Skill root 和 MCP 配置注入。 +- 命名:客户端列表名称与 Codex 运行时名称使用同一个原生标识,不维护 display/runtime 两套名称;重复或同名项保留为新的独立项并自动追加 `-2`、`-3`。Skill 重命名只修改客户端运行时副本中的有效名称,原始导入内容不修改。 +- Plugin 边界:Plugin 只作为导入容器提取 Skill/MCP;当前 DirectProject 关闭的 hooks、apps、remote plugin 和完整 Plugin Runtime 不接入。单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。 +- 信任边界:不审核第三方 Skill 文案、脚本、二进制、MCP tool 或网络行为;导入阶段不执行内容。客户端只做标准结构识别、必要配置解析和 zip staging 路径边界处理,且不向第三方扩展注入 AGC 凭据或内部路径。 +- 影响范围:AGC 客户端扩展设置 UI、客户端本地扩展存储、DirectProject Codex app-server 启动准备和 pool fingerprint;不新增 HTTP 服务、SpacetimeDB schema、公开 API 或独立 Plugin Runtime。 +- 当前实现:客户端导入/list、Skill 临时 root 和 MCP 隔离配置注入均已落地。第三方 MCP 只从客户端已启用独立项生成本次隔离 `CODEX_HOME/config.toml`,每项固定非 required;配置错误或 app-server 启动状态失败只更新对应 `last_error`,内置 `agc_tools` 继续由客户端单独注入。客户端已启用 Skill/MCP 的名称、来源路径和内容指纹共同参与 DirectProject app-server pool identity。 +- 验证方式:分三阶段验收:先验证导入拆分和完整列表,再验证 Skill 运行时发现和重命名,最后验证 MCP 配置合并、Plugin 提取和失败隔离;只增加对应的定向测试、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`、`apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs`。 + ## 2026-08-30 批准 GDD 直接进入做游戏链路 - 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index db230fbc1..9d754cb5d 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -22,13 +22,14 @@ AI 游戏创作 / DirectProject / UI workflow: 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` -2. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` -3. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` -4. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` -5. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` -6. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` -7. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` -8. UI 编辑器、宿主壳和当前测试专题文档 +2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md` +3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` +4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` +5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` +6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` +7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` +8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` +9. UI 编辑器、宿主壳和当前测试专题文档 图片画布 / 媒体生成: diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 4ee9e70b1..8190254eb 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -52,7 +52,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 ## AGC DirectProject 与 UI workflow - 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。 -- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP。它负责审核引用读取、标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用由客户端绑定回合、幂等账本、请求上限和投影权威。 +- DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP,并在启动时额外读取客户端扩展仓库中已启用的第三方 MCP 独立项。第三方 STDIO/HTTP 配置只写入本次隔离 `CODEX_HOME`,单项非 required,启停、重命名和内容指纹进入 app-server pool identity;完整 Plugin Runtime、hooks/apps 和单文件脚本手动指定入口仍关闭。`agc_tools` 继续负责审核引用读取、标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用仍由客户端绑定回合、幂等账本、请求上限和投影权威。 - DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。 - `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。 - UI workflow 的资源桥接与 Runtime 边界以 `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` 和 AGC 实施计划的 2026-08-24 覆盖段为准;只生成图片、登记空 JSON 或进入普通图片画布都不构成 workflow 完成。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 7a95b4117..39177166c 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1755,6 +1755,6 @@ V1.54 的公共编排层可以在运行前构造动态 DAG,但 LLM 在执行 ## DirectProject 原生工具边界覆盖(2026-08-24) -本文中 V1.1/V1.52 关于 app-server 全局关闭 native shell、network、browser、plugin 和 multi-agent 的表述继续适用于 ToolHost/DirectHome 与 legacy Runtime;不再作为 DirectProject 的现行实现。DirectProject 恢复原生文件/搜索/命令、图片查看和 Skill,并通过隔离 `CODEX_HOME` 只注入审核后的 `agc_tools` MCP。平台美术、资源投影、浏览器试玩、受控搜索、付费副作用和 durable delegation 仍必须走 AGC 权威链路。 +本文中 V1.1/V1.52 关于 app-server 全局关闭 native shell、network、browser、plugin 和 multi-agent 的表述继续适用于 ToolHost/DirectHome 与 legacy Runtime;不再作为 DirectProject 的现行实现。DirectProject 恢复原生文件/搜索/命令、图片查看和 Skill,始终注入审核后的 `agc_tools` MCP,并可在启动时从客户端扩展仓库接入用户已启用的独立第三方 MCP 配置;第三方配置不进入全局 Codex home,不开启完整 Plugin Runtime。平台美术、资源投影、浏览器试玩、受控搜索、付费副作用和 durable delegation 仍必须走 AGC 权威链路。 DirectProject 的写入根固定为真实 `game/`,审批策略为 `never`,原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`;shell 使用 Codex `shell_environment_policy` 的 glob 排除 API key、proxy、loopback bridge 和受控开关。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅获得连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。Codex 原生子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 以及未接入 AGC 证据链的浏览器/电脑控制保持关闭。系统提示词只传入最小身份、工作区、Skill 索引和副作用边界,不再批量注入源码快照或 Skill 正文。sandbox writableRoots 不提供 deny-read;`.agent` 与 `../assets` 的不可读约束仍需通过 prompt/Skill 行为合同和真实 smoke 验证,不能误称为 OS 强制隔离。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f6e032168..fda4e384c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1184,7 +1184,7 @@ game-project/ - 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。 - 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。 - `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。 -- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP,工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景和 desktop/mobile 浏览器试玩。MCP 进程只做协议;真实浏览器与付费 External v1 调用通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key、项目路径、revision、operation 或幂等键到模型上下文。已登记工具固定自动批准,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;通用 shell、Codex 原生 webSearch、任意网络、多 Agent、插件和外部 MCP 继续关闭。`codex_app_server` 模式要求 `llm.webSearchEnabled=false`。 +- DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP;2026-08-31 起还会在启动时接入客户端扩展仓库中用户已启用的独立第三方 STDIO/HTTP MCP 配置,但不读取用户全局 Codex MCP、不开启完整 Plugin Runtime。`agc_tools` 工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景和 desktop/mobile 浏览器试玩。内置 MCP 进程只做协议;真实浏览器与付费 External v1 调用通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key、项目路径、revision、operation 或幂等键到模型上下文。内置与用户启用的第三方 MCP 工具都沿用 DirectProject 自动批准方式,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;Codex 原生 webSearch、任意原生命令网络、多 Agent 和完整插件能力继续关闭。 - 陶泥儿生成继续复用持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记;普通客户端优先使用当前 AGC 登录会话及账号路由,只有受控的 ExternalDeveloper 发布模式才在客户端内部使用按服务器 origin 隔离的私有 Key。用户和模型都不需要提供或配置 API Key;凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。 - 自定义 LLM API Key 路由只在 DirectHome/DirectProject 经 loopback `/responses` 流式代理转发。代理不注入 Key,只要求请求自带 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,防止隔离 app-server 把 API Provider 误判为余额 0;旧 ToolHost 保持原 Provider 行为。 @@ -1249,8 +1249,7 @@ game-project/ 本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线,现由以下覆盖规则取代:DirectProject 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内恢复 Codex 原生文件/搜索/命令、图片查看和 Skill;其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。 -DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、插件、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`,明确清空外部 MCP 后只注入 `agc_tools`;配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。 - +DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`;provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。 ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。UI 编辑器独立的语义建议请求也必须复用统一 LLM 传输选择,`llm.stream=true` 时发送 `stream=true` 并聚合完整工具调用后再校验结果。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。 diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md new file mode 100644 index 000000000..bc6aeeafe --- /dev/null +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -0,0 +1,521 @@ +# DirectProject 客户端 Skill 与 MCP 扩展导入方案 + +更新时间:`2026-08-31` + +## 1. 文档定位 + +本方案是 DirectProject 客户端第三方 Skill、MCP 和 Plugin 导入能力的当前实现依据。它描述客户端侧的导入、拆分、命名、启用和 DirectProject 启动注入边界,不改变 AGC 内置 Skill Pack、`agc_tools` MCP、项目文件工具和现有 Runtime 权威。 + +本方案只覆盖客户端安装和下一次 Codex 启动时接入。第三方扩展不是安装到全局运行时 Codex,也不要求用户把市面上的扩展重新打包为 AGC 自定义格式。 + +## 2. 一句话交付结果 + +客户端提供一个全局“扩展”入口,直接接受文件、目录、zip 和标准 Plugin;导入后把其中发现的每个 Skill 和每个 MCP Server 拆成独立扩展项,用户可以分别重命名、启用、禁用和删除;DirectProject 启动 Codex 时只注入已启用的独立项。 + +## 3. 已确定的产品边界 + +### 3.1 客户端安装、运行时注入 + +- 扩展内容保存在 AGC 客户端的扩展仓库。 +- 不把扩展永久安装到用户的全局 `CODEX_HOME`。 +- DirectProject 创建或复用 app-server 时,读取客户端当前已启用的扩展并生成本次运行的临时 Skill root 与 MCP 配置。 +- 导入、启用、禁用和重命名不热更新正在运行的 Codex;变更从下一次 DirectProject 启动生效。 +- 扩展默认对客户端内所有 DirectProject 生效,不做项目级启用映射。 + +### 3.2 用户信任和最小处理 + +用户自行负责第三方扩展的信任判断。客户端不审核 Skill 文案、脚本、二进制、MCP tool description、网络域名或扩展行为。 + +客户端只做导入流程正常运行所需的最小结构处理: + +- 识别标准 Skill、MCP 和 Plugin 结构; +- 读取必要的 TOML/JSON 配置; +- 生成运行时名称和临时配置; +- 解压时防止路径穿越到 staging 目录外。 + +导入阶段不执行脚本、不启动 MCP、不调用第三方网络服务。 + +即使不审核第三方行为,也不得向第三方扩展注入 AGC provider key、Codex 登录态、Cookie、bridge token、内部服务令牌或客户端内部路径。 + +### 3.3 明确不支持 + +- 单个 `.exe`、`.py`、`.js` 或其它可执行文件/脚本由用户手动指定为 MCP 入口; +- 根据 README 或文件后缀猜测如何启动普通程序; +- 完整 Codex Plugin Runtime; +- Plugin hooks、apps、remote plugin 和依赖这些能力的运行时行为; +- 自定义扩展 manifest、审核清单或自定义安装包格式; +- Skill/MCP 行为安全扫描、脚本沙箱和网络白名单; +- 项目级扩展配置; +- 热更新、后台扩展服务、在线市场、版本历史、回滚和自动升级。 + +## 4. 导入与拆分模型 + +目录、zip 和 Plugin 是“导入来源”,不是管理对象。一个来源中识别出的每个 Skill 和每个 MCP Server 都独立成为客户端扩展项。 + +```text +导入来源 + ↓ +保存原始内容 + ↓ +发现标准结构 + ↓ +拆成独立 Skill/MCP 扩展项 + ↓ +分别列表显示、命名、启用和删除 +``` + +### 4.1 Skill 发现 + +以下内容分别导入为独立 Skill: + +- 根目录的 `SKILL.md`; +- 标准 Skill root 中的每个 `*/SKILL.md`; +- Plugin 中包含的标准 Skill 目录。 + +例如: + +```text +my-package/ +└── skills/ + ├── art-skill/SKILL.md + └── code-skill/SKILL.md +``` + +导入结果是两个独立项: + +```text +art-skill +code-skill +``` + +### 4.2 MCP 发现 + +以下配置中的每个 Server 分别导入为独立 MCP 项: + +- Codex 原生 `config.toml` 的 `[mcp_servers.]`; +- 兼容的 `.mcp.json` 顶层 `mcpServers.`; +- 标准 Plugin 中能够解析出的 MCP 配置。 + +例如一个配置包含: + +```toml +[mcp_servers.search] +command = "..." + +[mcp_servers.filesystem] +command = "..." +``` + +导入结果是两个独立项: + +```text +search +filesystem +``` + +`.mcp.json` 只作为兼容输入读取,不作为新的 AGC 配置格式;只支持能直接转换为 MCP Server 连接描述的字段,不为各客户端的私有扩展建立独立系统。 + +### 4.3 Plugin 处理 + +存在 `.codex-plugin/plugin.json` 时,将目录或 zip 识别为标准 Plugin 导入来源。 + +本期只提取其中可识别的 Skill 和 MCP,并分别创建独立扩展项: + +```text +Plugin +├── Skill A → 独立 Skill 项 +├── Skill B → 独立 Skill 项 +└── MCP C → 独立 MCP 项 +``` + +Plugin 自身不生成父级列表项,也不提供 Plugin 级开关。hooks、apps、remote plugin 以及依赖完整 Plugin Runtime 的内容忽略;如果没有任何可支持的 Skill/MCP,则来源保留为未知内容。 + +### 4.4 未知输入 + +- 单个无关文件或二进制:保存为一个 `unknown` 项,不执行。 +- 完全无法发现标准内容的目录或 zip:保存一个 `unknown` 项。 +- 含有已识别 Skill/MCP 的来源中其它普通文件:不把每个普通文件都变成独立项,但保留原始来源用于溯源。 + +未知项可以展示和删除,但没有有效的启动入口。 + +## 5. 命名规则 + +### 5.1 一个名称同时用于 UI 和 Codex + +每个独立 Skill/MCP 项使用一个有效名称: + +```text +name 当前名称,前端和 Codex 运行时使用 +original_name 原始名称,用于溯源,不随用户改名变化 +``` + +不维护 `display_name` 和 `runtime_name` 两套可见名称。 + +用户把 Skill 从 `art-skill` 改为 `pixel-art-skill` 后: + +- 前端列表显示 `pixel-art-skill`; +- Codex 的 Skill 列表也使用 `pixel-art-skill`; +- 客户端只在运行时副本中修改 Skill 的有效名称; +- 原始 `SKILL.md` 不修改。 + +MCP 重命名同理:客户端列表名称和运行时生成配置中的 Server key 使用同一个名称,原始 TOML/JSON 不修改。 + +### 5.2 自动重命名 + +每次重复导入都保留为新的独立项,不覆盖旧项、不去重。 + +名称冲突统一使用原生标识形式追加后缀: + +```text +art-skill +art-skill-2 +art-skill-3 +``` + +不使用 `art-skill (2)` 这一类仅适用于 UI 的第二套名称。 + +同一类型内名称需要唯一: + +- Skill 与 Skill 之间避免重复; +- MCP 与 MCP 之间避免重复; +- Skill 和 MCP 可以同名,因为属于不同的运行时命名空间。 + +Skill 名称冲突按 ASCII 大小写不敏感判断(例如 `Foo` 与 `foo` 视为冲突),以匹配 Windows 和默认大小写不敏感 macOS 文件系统上的运行时 Skill 目录;MCP Server 名称继续按原生大小写敏感规则处理。 + +如果名称相同但内容不同,视为名称冲突;如果内容指纹相同,视为重复导入。两种情况都保留新项并使用同一套后缀分配逻辑。 + +提示合并为一次普通通知,例如: + +```text +检测到重复或同名扩展,已自动命名为“art-skill-2”。 +``` + +内容指纹只用于识别重复导入,不用于安全审核。 + +### 5.3 手动重命名 + +前端允许用户直接编辑独立扩展项的 `name`: + +- 名称不能为空; +- 使用对应 Skill/MCP 的原生标识格式; +- 保存时检查同类型名称冲突; +- 冲突时自动分配 `-2`、`-3` 后缀并提示用户。 + +Skill 的手动重命名必须反映到 Codex 运行时名称。客户端保留原始内容,在临时运行副本中修改标准名称字段,不修改用户导入的源文件。 + +目录或 Plugin 中的多个 Skill 已经在导入时拆成独立项,因此用户可以分别重命名每个 Skill,不存在“重命名整个多 Skill 包”的操作。 + +## 6. 客户端存储 + +扩展仓库使用客户端本地存储和内部元数据,不对用户要求任何自定义包结构。 + +```text +ImportedSource +├── id +├── original_name +├── storage_path +└── fingerprint + +ExtensionItem +├── id +├── source_id +├── type: skill | mcp | unknown +├── name +├── original_name +├── source_relative_path +├── enabled +├── fingerprint +└── last_error +``` + +`source_id` 只用于来源溯源和清理,不形成可操作的父级扩展项,也不产生父子级联启用状态。 + +对于目录或 zip: + +- 原始来源保存一次; +- 每个发现的 Skill/MCP 保存独立元数据; +- 删除某个子项不影响其它子项; +- 原始来源不因删除一个子项而立即删除。 + +重复导入同一来源时创建新的来源和新的子项,名称按规则追加后缀。 + +## 7. 前端设计 + +### 7.1 入口 + +复用现有 [RuntimeConfigDialog.tsx](../../apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx) 的设置弹窗,在设置导航中增加“扩展”分区。 + +不在项目聊天区新增入口,不按项目创建扩展页面。 + +### 7.2 页面 + +页面只包含: + +- “导入扩展”按钮; +- 独立扩展项列表; +- 每项的名称、类型、来源摘要和当前状态; +- 启用/禁用开关; +- 重命名操作; +- 删除操作; +- 必要时的一行启动错误。 + +一个目录、zip 或 Plugin 中的多个内容直接平铺显示,不显示父级包和父级开关: + +```text +art-skill +Skill · 来自 my-plugin.zip 已启用 + +code-skill +Skill · 来自 my-plugin.zip 已禁用 + +search +MCP · 来自 my-plugin.zip 启动失败 +``` + +完整列表包括: + +- 已启用; +- 已禁用; +- 未识别; +- 当前不可用; +- 启动失败。 + +扩展列表首次读取完成前显示加载态;只有读取成功且结果为空时才显示“还没有导入扩展”,读取失败时显示加载错误,不把未完成的异步读取当作空列表。 + +“完整列表”指所有用户导入的独立扩展项,不指 zip 内每个普通文件或每个 MCP tool。 + +### 7.3 导入后的默认状态 + +已识别的 Skill/MCP 导入后默认启用,下一次 DirectProject 启动时加载。 + +未识别内容保留在列表中,但没有有效启动能力。 + +导入和状态变化不需要和 Agent 模型配置共用“保存”按钮,扩展操作可以单项即时保存。 + +## 8. DirectProject 运行时接入 + +### 8.1 Skill + +启动准备阶段: + +```text +读取 enabled=true 的 Skill 项 + ↓ +创建本次运行的临时 Skill root + ↓ +为重命名 Skill 生成运行时副本 + ↓ +调用 skills/extraRoots/set + ↓ +调用 skills/list 复核 +``` + +Skill root 是当前启用 Skill 集合的完整投影;每次准备时先清理已有运行时目录,再重新复制当前有效项,避免禁用、删除、重命名或源内容变化后的旧文件继续被 Codex 发现。 + +每个独立 Skill 的启用状态单独生效。Skill 内容中的脚本、二进制和引用资源不做行为扫描。 + +### 8.2 MCP + +启动准备阶段: + +```text +读取 enabled=true 的 MCP 项 + ↓ +为每个项生成 mcp_servers 配置 + ↓ +处理同类型 Server key 冲突 + ↓ +与 AGC 内置 agc_tools 合并 + ↓ +启动 Codex +``` + +每个 MCP Server 独立启用和禁用。相对路径按来源目录解析,原始 command、args、cwd、env 或 URL 按原生配置保留。 + +运行时只转换 Codex 原生能够直接使用的标准字段。STDIO 支持 `command / args / env / env_vars / cwd`;HTTP 支持 `url / bearer_token_env_var / http_headers / env_http_headers`;两类都支持原生启动超时、工具超时和工具筛选字段。客户端启用状态是运行时权威,每个第三方项固定 `enabled=true`、`required=false`,并沿用 DirectProject 无逐工具弹窗的自动批准方式。第三方 MCP 配置写入本次隔离 `CODEX_HOME/config.toml`;DirectProject 启动命令同时以 `-c mcp_optional_startup_grace_ms=120000` 传入同一值,命令行 override 是 Codex 最终生效来源。DirectProject 在首次 `turn/start` 前消费 app-server 已有的 `mcpServer/startupStatus/updated` 通知,等待每个已启用第三方 MCP 进入 `ready`、`failed` 或 `cancelled`;最长等待 `120000` 毫秒,超时后继续对话。ready 的 MCP 进入首轮,失败或超时的 MCP 被跳过并记录状态。该配置只作用于本次 DirectProject 隔离运行,不修改用户全局 Codex 配置。DirectProject 系统提示会声明客户端扩展列表中已启用的第三方 MCP;用户明确指定 Server 或工具时,模型只在当前可用工具中查找,找不到则如实说明,不伪造结果。 + +客户端扩展索引的所有读改写操作在进程内使用同一把互斥锁串行化;原子写文件只负责避免半截文件,不能替代这层读改写协调。每个运行时 MCP 同时保留客户端扩展项 ID 和当前 app-server 连接 token,启动状态通知按“连接建立时的 Server 名称 → 扩展项 ID + 连接 token”映射回写;迟到的旧连接通知、扩展启停/删除后的失效 token 直接忽略。连接 token 只存在进程内 owner registry,不写入持久化索引。已启用 MCP 的连接池 fingerprint 包含扩展项 ID 和来源 ID,避免删除后重导入相同内容时复用旧连接。 + +用户导入来源中的相对 `cwd` 按该 MCP 配置文件所在目录解析;未声明 `cwd` 的 STDIO Server 默认以该目录启动,从而保留脚本参数的原生相对路径语义。客户端不替用户猜测普通文件如何启动。 + +`agc_tools` 为 AGC 内部保留名称,第三方扩展不得覆盖。第三方 MCP 的工具调用继续继承 DirectProject 当前既有运行策略;本功能不新增逐工具审核系统。 + +客户端不会把 AGC provider session token、工具桥地址或受控搜索标记通过 `env_vars`、`bearer_token_env_var`、`env_http_headers` 转发给第三方 MCP。扩展自身静态声明的 `env` 和 `http_headers` 仍按原生配置使用。OAuth 登录 UI、OAuth 凭据中心和自动登录不属于本阶段。 + +### 8.3 失败处理 + +第三方扩展的解析或启动失败时: + +- 记录对应独立项的 `last_error`; +- 列表显示“启动失败”或“当前不可用”; +- 尽量跳过失败项,保留 AGC 内置 Skill 和 `agc_tools`; +- 不自动重试、不启动后台修复服务、不删除原始内容。 + +DirectProject 首轮使用 Codex 已有的 Eager MCP 启动和 AGC 侧有界 readiness gate:所有已启用第三方 MCP 并行尝试启动,AGC 在首次 `turn/start` 前等待现有 app-server 状态通知,最多等待 `120000` 毫秒;等待实现必须先注册 `Notify` future,再读取共享状态,以免 `notify_waiters` 在状态检查和等待注册之间丢失唤醒;不执行工具探测调用,也不新增外部 MCP 服务。第三方 MCP 仍保持 `required=false`,单项进入 `failed`/`cancelled` 或等待超时后,DirectProject 继续对话;后续回合可重新发现等待窗口结束后才 ready 的 MCP。 + +扩展集合的 fingerprint 纳入现有 DirectProject app-server pool key,扩展集合变化后不复用不匹配的旧运行实例。 + +## 9. 客户端接口 + +Skill 和 MCP 共用客户端扩展接口: + +```text +list_client_extensions() +import_client_extension(path) +set_client_extension_enabled(id, enabled) +rename_client_extension(id, name) +remove_client_extension(id) +``` + +不新增按类型分开的安装接口。导入接口返回拆分后的多个独立项: + +```json +{ + "imported": [ + { + "id": "extension-...", + "name": "art-skill", + "originalName": "art-skill", + "extensionType": "skill", + "sourceName": "bundle.zip", + "sourceRelativePath": "skills/art/SKILL.md", + "enabled": true, + "status": "enabled", + "lastError": null + }, + { + "id": "extension-...", + "name": "search", + "originalName": "search", + "extensionType": "mcp", + "sourceName": "bundle.zip", + "sourceRelativePath": "mcp.json", + "enabled": true, + "status": "enabled", + "lastError": null + } + ], + "sourceName": "bundle.zip", + "renamed": false, + "duplicate": false +} +``` + +以上是 Tauri command 对外返回的 camelCase DTO。未识别内容不会生成顶层 `unknown` 字段,而是作为 `imported` 中的一项返回,其 `extensionType` 为 `"unknown"`、`enabled` 为 `false`、`status` 为 `"unknown"`。 + +前端不直接处理 Skill root 路径、MCP command 或 zip staging 路径;这些属于客户端后端和 DirectProject 启动适配层。 + +## 10. 分阶段实施与验收 + +不采用一次性大改动。按垂直闭环分三阶段,每阶段完成后验收再进入下一阶段。 + +### 阶段 0:冻结契约和样例 + +确认: + +- 导入来源与独立扩展项的关系; +- 名称统一使用原生标识; +- 重复导入自动追加后缀; +- 已识别内容默认启用; +- Plugin 只作为导入容器; +- 单个可执行文件/脚本不作为 MCP 入口; +- 客户端全局生效。 + +准备最小样例并确认每个样例的预期拆分结果。 + +阶段 0 样例位于 `apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/`,其 `README.md` 和 `expected-imports.json` 是本阶段输入分类、拆分和命名预期的机器可读/人工可读基线。zip 场景使用 `mixed-source/` 在测试运行时生成临时归档,不提交生成的二进制 zip。 + +### 阶段 1:客户端导入和列表 + +当前实施状态:已完成。客户端侧已落地本地扩展索引、来源副本、文件/目录/zip 导入、标准 Skill/MCP 结构发现、独立项拆分、原生命名、重复导入命名、启用/禁用、重命名、删除和设置弹窗列表。DirectProject 运行时注入仍留在阶段 2/3,不属于本阶段。阶段 1 定向 Rust 测试 6 项全部通过,前端 `typecheck` 和配置契约检查通过。 + +完成: + +- 客户端存储; +- 文件、目录、zip 导入; +- Skill/MCP/Plugin 结构发现; +- 多内容拆分为独立项; +- 未知项保留; +- 默认启用; +- 原生名称自动追加后缀; +- 手动重命名; +- 独立启用、禁用、删除; +- 设置弹窗中的完整列表。 + +验收: + +- 一个来源包含多个 Skill/MCP 时,列表出现多个独立项; +- 相同来源重复导入时不覆盖旧项; +- 同名和重复内容均自动使用 `-2`、`-3`; +- 单个二进制不会被启动; +- 原始来源内容未被改写。 + +### 阶段 2:Skill 运行时闭环 + +当前实施状态:已完成。DirectProject 启动准备会在隔离 HOME 中分别创建内置 AGC Skill root 和客户端 Skill root,并通过同一次 `skills/extraRoots/set` 注册;客户端 Skill root 只包含当前 `enabled=true` 的 Skill 独立副本。用户重命名时仅在该临时副本的标准 frontmatter 中更新 `name`,原始来源副本和客户端索引保持不变。已启用 Skill 集合的名称、来源相对路径和内容指纹已纳入 DirectProject app-server pool key,启停或重命名从下一次启动生效,不热更新现有连接。 + +完成: + +- 读取客户端已启用 Skill; +- 临时 Skill root 生成; +- Skill 重命名同步到运行时副本; +- `skills/extraRoots/set` 和 `skills/list` 接入; +- 扩展 fingerprint 纳入 app-server pool key。 + +验收: + +- 多个 Skill 可以独立发现; +- 重命名后的 Skill 在 `skills/list` 中使用新名称; +- 单独禁用一个 Skill 不影响其它 Skill; +- 当前 Codex 不热更新; +- AGC 内置 Skill 不受影响。 + +### 阶段 3:MCP 和 Plugin 部分闭环 + +当前实施状态:已完成。客户端会在 DirectProject 启动前读取所有已启用 MCP 独立项,把可转换的 STDIO/HTTP 原生字段合并进本次隔离 `CODEX_HOME/config.toml`,再由现有启动参数单独注入内置 `agc_tools`。第三方项固定为非 required,结构错误或启动失败只更新对应扩展项的 `last_error`;Codex app-server 的 `mcpServer/startupStatus/updated` 通知用于清除或记录单项启动状态。已启用 MCP 的名称、来源相对路径和内容指纹已纳入 app-server pool key。Plugin 样例已覆盖同一来源中的 Skill/MCP 独立提取,不开启 Plugin Runtime。 + +完成: + +- `config.toml` 每个 Server 独立导入; +- `.mcp.json` 每个 Server 独立导入; +- Plugin 中 Skill/MCP 独立导入; +- MCP Server 重名自动追加原生后缀; +- MCP 运行时配置合并; +- 单项错误记录和内置能力保留。 + +验收: + +- 一个 MCP 配置包含多个 Server 时,列表出现多个独立项; +- 每个 MCP 可以单独启用、禁用和重命名; +- 重名 Server 能生成稳定的唯一 key; +- Plugin 不会开启 hooks、apps 或完整 Plugin Runtime; +- 单个可执行文件/脚本仍不会自动作为 MCP 入口。 + +## 11. 最小验证范围 + +只增加和本功能直接相关的定向验证: + +- 多 Skill/MCP 来源拆分; +- 原生名称自动重命名和手动重命名; +- 重复导入保留多个独立项; +- Skill 运行时名称复核; +- MCP Server key 冲突; +- 未知文件不执行; +- zip 解压路径边界。 + +不新增完整第三方扩展行为测试、安全扫描框架、在线测试服务或大规模 E2E 基础设施。 + +## 12. 非目标和后续议题 + +以下内容不属于本方案的交付判据: + +- 完整 Codex Plugin Runtime; +- Plugin hooks/apps/remote plugin; +- 单文件 MCP 入口配置; +- 项目级扩展开关; +- MCP 工具逐项权限管理; +- OAuth/密钥管理中心; +- 在线市场和自动更新; +- 第三方扩展安全评级和行为沙箱。 + +后续如果要支持这些能力,必须另立技术方案,不在本方案中预留复杂抽象或隐藏开关。 diff --git a/docs/technical/【问题记录】DirectProject客户端Skill自然语言触发能力缺口-2026-09-01.md b/docs/technical/【问题记录】DirectProject客户端Skill自然语言触发能力缺口-2026-09-01.md new file mode 100644 index 000000000..03c9e4b64 --- /dev/null +++ b/docs/technical/【问题记录】DirectProject客户端Skill自然语言触发能力缺口-2026-09-01.md @@ -0,0 +1,185 @@ +# DirectProject 客户端 Skill 自然语言触发能力缺口 + +状态:待提 Issue,当前仅记录现状和候选方向,未修改代码。 + +## 1. 问题摘要 + +AGC 已经可以把用户导入的 Skill 安装到客户端,并在下一次 DirectProject 启动时注入真实 Codex app-server。使用 Codex 原生的 `$skill-name` 形式时,导入的 Skill 可以被读取并执行。 + +目前缺少的是:用户在普通自然语言中直接提到 Skill 名称时,AGC 没有把这次请求确定性地绑定为 Codex 的 Skill 输入项。结果是模型可能只看到用户提到了一个名称,并把“调用 Skill”误解成调用一个工具,返回“没有可调用接口”,而不是读取 `SKILL.md`。 + +这不是 Skill 导入失败,也不是需要把 Skill 改造成 MCP 工具;是 AGC 客户端没有复用 Codex 官方客户端的 Skill 输入适配层。 + +## 2. 当前 AGC 如何使用 Skill + +### 2.1 客户端导入 + +用户在客户端设置的“扩展”页面导入文件、目录或 zip。目录或 zip 中发现的每个 `SKILL.md` 会成为独立的 Skill 扩展项,可以单独启用、禁用、重命名和删除。 + +已识别的 Skill 导入后默认启用。扩展内容保存于 AGC 客户端扩展仓库,不直接写入用户全局 `CODEX_HOME`。 + +### 2.2 DirectProject 启动时注入 + +创建或复用 DirectProject 的 Codex app-server 时,AGC 会: + +1. 读取客户端扩展索引中 `enabled=true` 的 Skill; +2. 在本次隔离运行的临时 HOME 下创建客户端 Skill root; +3. 将每个 Skill 的运行时副本复制到该目录; +4. 对手动重命名的 Skill,只在运行时副本的 frontmatter 中使用当前名称; +5. 调用 Codex app-server 的 `skills/extraRoots/set`; +6. 调用 `skills/list` 重新发现 Skill。 + +AGC 侧的运行时目录准备位于 [`client_extensions.rs`](../../apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs) 的 `prepare_enabled_client_skill_root`,app-server 的 Skill root 注册位于 [`codex_app_server.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs)。 + +Skill 配置从下一次 DirectProject 启动生效,当前已经运行的 Codex 会话不热更新。首页的 DirectHome 不作为客户端 Skill 的运行时入口。 + +### 2.3 当前可用的触发方式 + +Codex 原生显式触发方式是: + +```text +$import-smoke-skill 请只回复一行结果。 +``` + +这个方式已经在 AGC 中验证成功,测试 Skill 返回: + +```text +SKILL_IMPORT_OK +``` + +这证明导入、启用、运行时 Skill root 注入、Codex 发现和 Skill 正文执行链路已经打通。 + +## 3. 当前缺少的能力 + +### 3.1 普通自然语言名称没有确定性绑定 + +以下输入目前不能稳定触发导入的 Skill: + +```text +请调用 import-smoke-skill skill。 +``` + +```text +请明确使用 import-smoke-skill。 +``` + +实际结果是模型报告“当前没有可调用该 Skill 的工具接口”。这句话在“Skill 不是工具”这一点上没有错,但没有完成 Skill 指令的读取和应用。 + +### 3.2 AGC 没有发送结构化 Skill 输入项 + +Codex 官方 TUI 在识别 `$skill-name` 或用户从 Skill 提及菜单选择后,会向 `turn/start` 输入中追加类似内容: + +```json +{ + "type": "skill", + "name": "import-smoke-skill", + "path": ".../SKILL.md" +} +``` + +对应实现见 Codex 仓库中的 `codex-rs/tui/src/chatwidget/input_submission.rs`。随后 Codex core 会根据结构化输入加载 Skill 正文。 + +AGC 当前的 [`codex_app_server_turn_input`](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs) 只把用户消息整理成普通文本和图片输入,没有追加 `type: "skill"` 项。 + +### 3.3 “能发现”与“本轮已使用”没有明确的客户端绑定 + +AGC 启动时会设置 `skills/extraRoots` 并调用 `skills/list`,这只能证明 Skill 在当前 app-server 进程的发现范围内。它不等于本轮已经选中了某个 Skill,也不等于模型一定会读取该 Skill 的正文。 + +Codex 的 Skill 扩展会把可用 Skill 目录和使用规则放入模型上下文,但普通文本相关性判断主要交给模型;它不是 AGC 客户端已经完成的确定性绑定。 + +## 4. Codex 源码中的相关分层 + +### 4.1 模型侧使用规则 + +Codex 仓库中的 `codex-rs/ext/skills/src/catalog_prompt.rs` 会向模型说明:用户用 `$SkillName` 或普通文本点名 Skill,或者任务符合 Skill 描述时,应使用该 Skill。 + +这是一条模型行为指令,不是一个保证命中的 `if user_text.contains(...)` 路由器。普通自然语言是否命中,仍取决于模型能否看到对应目录、是否理解名称和描述,以及是否调用 `skills.read`。 + +### 4.2 显式 Skill 选择 + +Codex 仓库中的 `codex-rs/ext/skills/src/selection.rs` 会处理结构化 `UserInput::Skill`,也会解析文本中的 `$skill-name`。选中后,core 在回合开始阶段读取并注入 Skill 正文。 + +### 4.3 “隐式 Skill”不是普通自然语言路由 + +源码中的 `implicit invocation` 主要用于命令或脚本访问某个 Skill 的资源目录时记录/识别对应 Skill,入口在 Codex 仓库的 `codex-rs/ext/skills/src/invocation.rs`。它不是一个根据任意用户自然语言自动选择 Skill 的通用分类器。 + +### 4.4 相关性选择实验不是当前确定性注入 + +Codex 的 `shadow_selection` / `skill_search` 相关实现会对任务和 Skill 描述做词法/相关性排序,但当前是 shadow 观测路径,不直接替当前回合注入 Skill,不能作为 AGC 当前缺口的现成解决方案。 + +## 5. 复现步骤 + +准备一个普通的 Skill 导入目录,目录中放置一个 `SKILL.md`。例如: + +```markdown +--- +name: import-smoke-skill +description: 用于验证客户端导入和加载 Skill 的最小测试 Skill。 +--- + +当用户明确要求使用 `import-smoke-skill` 时,先单独输出一行 `SKILL_IMPORT_OK`,再继续回答用户的问题。 +``` + +该目录和文件可以由 Issue 提交者在自己的测试环境中临时创建,不依赖仓库外的固定目录或本机产物。 + +复现: + +1. 在客户端“扩展”中导入包含上述 `SKILL.md` 的目录; +2. 确认列表显示 `import-smoke-skill`、类型为 Skill、状态为“已启用”; +3. 新开一个 DirectProject 会话; +4. 依次发送: + + ```text + 请调用 import-smoke-skill skill,如果找不到这个skill,不要伪造结果。 + ``` + + ```text + 请明确使用 import-smoke-skill,只回复一行结果。 + ``` + + ```text + $import-smoke-skill 请只回复一行结果。 + ``` + +预期现象:前两条不会稳定加载 Skill,第三条返回 `SKILL_IMPORT_OK`。 + +## 6. 可能的修复方向 + +以下只是供 Issue 讨论的候选方向,不在本文锁定具体实现。 + +### 方向 A:客户端显式绑定用户点名的 Skill + +AGC 在发送回合前,根据当前已启用 Skill 列表识别用户明确提到的名称,并把匹配项转换成 Codex 原生的 `type: "skill"` 输入项,同时保留原始用户文本。 + +优点是行为确定、与官方 TUI 的协议一致;需要讨论名称边界、重名处理和用户文本中普通单词与 Skill 名称的冲突。 + +### 方向 B:增加 Skill 提及选择入口 + +在聊天输入框提供 Skill 名称补全或选择入口。用户选择后,客户端保存/发送结构化 Skill 绑定,而不是要求用户手写 `$`。 + +优点是用户不需要了解 Codex 协议标记;需要额外的前端交互设计,且要决定是否在 DirectProject 聊天区增加入口。 + +### 方向 C:仅支持 Codex 原生 `$skill-name` + +保持当前实现,只把 `$skill-name` 作为 Skill 的正式调用方式,并在测试和使用说明中明确这一点。 + +优点是改动最小;缺点是用户需要知道 Codex 的 Skill 提及语法,普通中文“请使用某 Skill”仍不会得到确定性支持。 + +### 方向 D:依赖模型根据 Skill 目录自行判断 + +让 Skill 目录和描述保持模型可见,由模型在普通任务中自行判断是否需要调用 `skills.read`。 + +这种方式可以覆盖“任务适合某 Skill”而不仅是精确名称,但结果受模型判断和上下文影响,不适合作为“用户明确点名后必须使用”的唯一保证。 + +## 7. Issue 建议关注点 + +提 Issue 时建议先确认产品契约,而不是直接指定实现: + +- “用户普通中文提到已启用 Skill 名称”是否必须确定性生效; +- 是否接受用户使用 Codex 原生 `$skill-name`; +- 是否需要聊天输入框提供 Skill 选择/补全; +- Skill 名称与 MCP Server 名称冲突时,客户端按哪类扩展解析; +- 没有找到匹配 Skill 时,是否只给出说明并继续普通对话; +- 是否需要在回合记录或状态 UI 中展示本轮实际注入了哪些 Skill。 + +本 Issue 不应扩大为:重新设计 Skill/MCP 导入格式、把 Skill 做成 MCP 工具、增加第三方内容安全扫描、增加脚本沙箱,或重做 Codex Plugin Runtime。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 8ab8c5231..293154ad9 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -177,7 +177,7 @@ function assertNativeShellDependencyVersionGuardrails() { "'eas-cli': '^20.3.0'", "assertPackageLockVersion('apps/mobile-shell', 'eas-cli', '20.3.0')", ]) { - if (!mobileShellConfigCheckSource.includes(snippet)) { + if (!sourceIncludesSnippet(mobileShellConfigCheckSource, snippet)) { throw new Error( `mobile shell dependency guardrail drifted: missing ${snippet}`, ); @@ -201,7 +201,7 @@ function assertNativeShellDependencyVersionGuardrails() { "['tauri', '2.11.2']", 'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }', ]) { - if (!desktopShellConfigCheckSource.includes(snippet)) { + if (!sourceIncludesSnippet(desktopShellConfigCheckSource, snippet)) { throw new Error( `desktop shell dependency guardrail drifted: missing ${snippet}`, ); @@ -2440,7 +2440,7 @@ function assertAiGameCreatorShellUserDevBoundary() { 'if (!import.meta.env.DEV)', "return params.has('dev') || window.location.hash === '#dev';", ]) { - if (!aiGameCreatorShellAppModelSource.includes(snippet)) { + if (!sourceIncludesSnippet(aiGameCreatorShellAppModelSource, snippet)) { throw new Error( `AI game creator developer mode boundary drifted: missing ${snippet}`, ); @@ -2451,7 +2451,7 @@ function assertAiGameCreatorShellUserDevBoundary() { '{devMode ? (', 'className="developer-pane"', ]) { - if (!aiGameCreatorShellAppSource.includes(snippet)) { + if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) { throw new Error( `AI game creator user/dev UI boundary drifted: missing ${snippet}`, ); @@ -2525,7 +2525,9 @@ function assertAiGameCreatorShellUserDevBoundary() { 'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"', 'src={embeddedUrl}', ]) { - if (!aiGameCreatorLocalGamePreviewFrameSource.includes(snippet)) { + if ( + !sourceIncludesSnippet(aiGameCreatorLocalGamePreviewFrameSource, snippet) + ) { throw new Error( `AI game creator embedded preview boundary drifted: missing ${snippet}`, ); @@ -2535,7 +2537,7 @@ function assertAiGameCreatorShellUserDevBoundary() { "await invoke(\n 'activate_local_game_preview'", '已切换到客户端运行视图', ]) { - if (!aiGameCreatorShellAppSource.includes(snippet)) { + if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) { throw new Error( `AI game creator client preview activation drifted: missing ${snippet}`, ); @@ -2545,7 +2547,7 @@ function assertAiGameCreatorShellUserDevBoundary() { 'fn activate_local_game_preview(', 'preview_open_url(&status)?;', ]) { - if (!aiGameCreatorPreviewRustSource.includes(snippet)) { + if (!sourceIncludesSnippet(aiGameCreatorPreviewRustSource, snippet)) { throw new Error( `AI game creator client preview command drifted: missing ${snippet}`, ); @@ -2576,7 +2578,7 @@ function assertAiGameCreatorShellUserDevBoundary() { '项目总控对话窗口仅在开发构建中可用', 'index.html?supervisor-chat&projectPath=', ]) { - if (!aiGameCreatorShellTauriSource.includes(snippet)) { + if (!sourceIncludesSnippet(aiGameCreatorShellTauriSource, snippet)) { throw new Error( `AI game creator supervisor chat window must stay developer-only: ${snippet}`, ); @@ -3242,17 +3244,20 @@ function assertH5HostBridgePayloadBoundaries() { ); } if ( - !h5HostBridgeSource.includes( + !sourceIncludesSnippet( + h5HostBridgeSource, 'function normalizeNativeAppPageUrl(url: string)', ) || - !h5HostBridgeSource.includes("trimmedUrl.startsWith('//')") || - !h5HostBridgeSource.includes( + !sourceIncludesSnippet(h5HostBridgeSource, "trimmedUrl.startsWith('//')") || + !sourceIncludesSnippet( + h5HostBridgeSource, 'nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN', ) || - !h5HostBridgeSource.includes( + !sourceIncludesSnippet( + h5HostBridgeSource, 'const normalizedUrl = normalizeNativeAppPageUrl(url);', ) || - !h5HostBridgeSource.includes('{ url: normalizedUrl },') + !sourceIncludesSnippet(h5HostBridgeSource, 'url: normalizedUrl,') ) { throw new Error( 'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage', @@ -3281,7 +3286,7 @@ function assertH5HostBridgePayloadBoundaries() { "rejects.not.toThrow(\n 'private native detail',", 'expect(consoleError.mock.calls.flat()).not.toContain(navigationError)', ]) { - if (!h5HostBridgeTestSource.includes(snippet)) { + if (!sourceIncludesSnippet(h5HostBridgeTestSource, snippet)) { throw new Error( `H5 HostBridge navigation failure test must include ${snippet}`, ); @@ -3556,7 +3561,7 @@ function assertH5NativeAppMessageSourceBoundaries() { 'if (!isNativeInjectedMessageEvent(event))', ]; for (const snippet of requiredSourceSnippets) { - if (!nativeAppHostBridgeSource.includes(snippet)) { + if (!sourceIncludesSnippet(nativeAppHostBridgeSource, snippet)) { throw new Error( `H5 native app transport must verify injected message source and origin: ${snippet}`, ); @@ -3571,7 +3576,7 @@ function assertH5NativeAppMessageSourceBoundaries() { 'expect(listener).not.toHaveBeenCalled();', ]; for (const snippet of requiredTestSnippets) { - if (!nativeAppHostBridgeTestSource.includes(snippet)) { + if (!sourceIncludesSnippet(nativeAppHostBridgeTestSource, snippet)) { throw new Error( `H5 native app transport source boundary test must include ${snippet}`, ); @@ -3844,7 +3849,7 @@ function assertNativeShellCapabilityPlan() { 'let response = resolve_host_bridge_request(request(method));', 'assert_eq!(error.code, "unsupported_method");', ]) { - if (!desktopDispatchSource.includes(snippet)) { + if (!sourceIncludesSnippet(desktopDispatchSource, snippet)) { throw new Error( 'desktop dispatch test must derive unsupported methods from Rust capability list', ); @@ -4147,7 +4152,7 @@ function sourceIncludesSnippet(source, snippet) { .replace(/\s+/g, ' ') .replace(/\s*([()[\]{}])\s*/g, '$1') .replace(/\s*,\s*/g, ',') - .replace(/,\)/g, ')') + .replace(/,([)}\]])/g, '$1') .trim(); return source.includes(snippet) || compact(source).includes(compact(snippet)); } @@ -4421,7 +4426,7 @@ function assertWechatPaymentResultBoundaries() { "logWechatPayFailure('requestVirtualPayment unavailable')", "logWechatPayFailure('requestVirtualPayment failed', error)", ]) { - if (!paymentSource.includes(snippet)) { + if (!sourceIncludesSnippet(paymentSource, snippet)) { throw new Error(`wechat payment bridge must include ${snippet}`); } } @@ -4451,7 +4456,7 @@ function assertWechatPaymentResultBoundaries() { "expect(console.error).toHaveBeenCalledWith(\n '[wechat-pay] requestVirtualPayment failed'", 'expect(console.error.mock.calls.flat()).not.toContain(payError)', ]) { - if (!paymentTestSource.includes(snippet)) { + if (!sourceIncludesSnippet(paymentTestSource, snippet)) { throw new Error(`wechat payment bridge test must include ${snippet}`); } } @@ -4605,7 +4610,7 @@ function assertWechatWebViewPageEventBoundaries() { "expect(console.info).toHaveBeenCalledWith('[web-view] message')", 'expect(console.info.mock.calls.flat()).not.toContain(webViewDetail)', ]) { - if (!webViewTestSource.includes(snippet)) { + if (!sourceIncludesSnippet(webViewTestSource, snippet)) { throw new Error( `wechat web-view page event boundary test must include ${snippet}`, ); @@ -4632,7 +4637,7 @@ function assertWechatShareGridFailureBoundaries() { 'reject(new Error(WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE))', 'errorMessage: WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE', ]) { - if (!shareGridSource.includes(snippet)) { + if (!sourceIncludesSnippet(shareGridSource, snippet)) { throw new Error(`wechat share-grid shell must include ${snippet}`); } } @@ -4659,7 +4664,7 @@ function assertWechatShareGridFailureBoundaries() { "expect(consoleError).toHaveBeenCalledWith('[share-grid] save failed')", "expect(consoleError.mock.calls.flat()).not.toContain('private native download detail')", ]) { - if (!shareGridTestSource.includes(snippet)) { + if (!sourceIncludesSnippet(shareGridTestSource, snippet)) { throw new Error( `wechat share-grid boundary test must include ${snippet}`, );