diff --git a/Cargo.lock b/Cargo.lock index 07fc074d3b..f4858f3140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6058,6 +6058,7 @@ dependencies = [ "sha2", "tempfile", "tokio", + "url", "urlencoding", ] diff --git a/docs/architecture/deep-review.md b/docs/architecture/deep-review.md index 770f3f0182..21d36a3bce 100644 --- a/docs/architecture/deep-review.md +++ b/docs/architecture/deep-review.md @@ -192,6 +192,13 @@ catalog behind a deferred tool specification, so a direct primary review pays no catalog input cost. Full selected guidance enters model context only after admission. +The Skill catalog follows the runtime registry's invocation eligibility. External +discovery alone does not admit guidance: the user must import a native copy first. +Imported copies retain their source parser semantics through the import record, +including Claude skills without explicit names. Resolution rechecks the native +key and implicit-invocation policy, so undoing an import or disabling implicit +invocation also invalidates an earlier catalog selection. + The existing manifest and backend admission path gain one small typed `focused_assignment` projection: question id, target fingerprint, allowed changed paths or packet id, expected evidence, capability key, and capability diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 4e109e5448..4173175789 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -27,14 +27,15 @@ Codex/Claude Code 运行时适配和外部 Subagent 续接仍属于后续阶段 Codex role 仍仅作为 Subagent,不能因来源被识别就宣称宿主运行时兼容。OpenCode、Claude Code 与 Codex 的本地 Hook 脱敏目录 已作为独立只读切片接入;在此之上,Claude Code 与 Codex 的同步 command 子集可经精确命令审阅复制为 OpenBitFun 管理的 原生 Hook 层,仍由唯一 `AgentHookEngine` 执行。OpenCode handler、非 command/异步 handler 和未审阅声明仍不可执行。 -独立的 MCP C0a 快照导入复用上述来源与现有 MCP 配置 owner:Desktop 和根 CLI 可预览 OpenCode、Claude Code -与 Codex 中语义等价的安全声明,并在用户显式确认后原子写入 disabled 原生条目。凭据/header/env/cwd 迁移、 -通用导入记录、undo、Peer/Remote 写入均未实现;这不改变外部 MCP 持续兼容来源的运行路径。 +独立的 MCP C0a 快照导入复用上述来源与现有 MCP 配置 owner:Desktop 和根 CLI 可预览 OpenCode、Claude Code、 +Codex 与 DeepSeek Harness 中受支持的安全声明,并在用户显式确认后原子写入 disabled 原生条目。静态 env/header +值随私有投影复制,来源信息保存在原生配置中;GUI 可批量导入并撤销本机副本。动态凭据引用解析和 Peer/Remote +写入仍不支持;这不改变外部 MCP 持续兼容来源的运行路径。 ## 0. 当前 MCP 快照导入契约(C0a) 快照导入是显式复制,不是持续同步,也不改变现有外部 MCP 兼容来源。Desktop 与根 CLI 只负责展示脱敏预览并发送 -typed intent;OpenCode、Claude Code 与 Codex sibling adapter 复用各自已合并的解析结果生成私有安全投影,外部来源 +typed intent;OpenCode、Claude Code 与 Codex sibling adapter 复用各自已合并的解析结果,DeepSeek Harness adapter 读取独立的完整声明,生成私有安全投影。外部来源 协调器固定当前 candidate 与行为版本,core 负责重新规划,最终仍由唯一 MCP 配置 service 校验并写入 `mcp_servers`。Codex 的投影与其运行准备共用同一当前 candidate/version fencing,不建立第二套解析或缓存。 @@ -43,36 +44,83 @@ native ID、disposition 和稳定 reason code,不包含 command arguments、UR `MCPServerConfig`。provider 私有投影不可序列化且使用 redacted `Debug`;plan/request 最多包含 256 个 candidate,未知请求 字段与重复选择直接拒绝。 +发现任务在进程共享并发预算下排队,并保留原有扫描及延迟完成期限。一次刷新中的提供方超过 worker 数量时不能被 +直接丢弃为过载;导入预览也不能把待完成或调度失败的扫描当作完整目录。 + 当前只复制能够与原生配置保持等价语义的声明: -- 无显式 environment/cwd 的 local stdio command 与 adapter 已解析 arguments; -- 无 userinfo、query、fragment、header、bearer token 或 provider OAuth 变化的 HTTPS streamable HTTP URL。 +- local stdio command、adapter 已解析 arguments、静态 environment 和绝对 working directory; +- 无 userinfo、query、fragment 的 HTTPS streamable HTTP URL、静态 headers,保留显式 OAuth discovery 开关; +- adapter 已校验的 startup/catalog/execution 毫秒超时。原生 JSON 的读写与再次保存均保留这些可选字段,旧配置缺省语义不变。 -environment 值或引用、header/authorization、cwd、未知字段和其他 transport 不猜测、不复制、不记录。导入条目始终为 +environment/header 的静态值仅经不可序列化、Debug 脱敏的私有投影复制到原生配置,并纳入计划摘要;目录和确认界面只展示字段名。 +未解析的变量或凭据引用、未知字段和其他 transport 不猜测、不复制。导入条目始终为 `enabled: false` 与 `autoStart: false`;local 条目不继承完整父进程环境,只保留 MCP runtime owner 提供的安全环境。 Codex 的 legacy `name` 是上游忽略的展示字段,不进入导入结果或行为版本;`startup_timeout_sec`(含旧 -`startup_timeout_ms`)和 `tool_timeout_sec` 可进入受审批保护的兼容运行投影,但当前原生快照格式不能无损保留它们, -因此仍会阻断 C0a 导入。`enabled_tools`、`disabled_tools`、approval、environment/scopes/OAuth 与并行调用等运行敏感字段 +`startup_timeout_ms`)和 `tool_timeout_sec` 同时进入受审批保护的兼容运行投影和原生快照导入。 +`enabled_tools`、`disabled_tools`、approval、scopes/OAuth 凭据与并行调用等运行敏感字段 仍按不支持处理,不能因静态发现成功而丢弃语义后导入。 -Codex 未显式声明 cwd 时,其兼容运行投影仍会把当前 workspace 作为 effective cwd;现有原生快照格式不会保留这项隐式 -语义,因此 workspace 场景的 local 声明返回“需要设置”,不能以“没有 cwd 字段”为由导入后继承 OpenBitFun 进程目录。 +Codex 和 OpenCode 未显式声明 cwd 时,当前 workspace 产生的 effective cwd 也会随导入保留;不能因上游未写 cwd +就改为继承 OpenBitFun 进程目录。工作目录仅通过私有投影传到配置 owner,不加入公开预览。 + +DeepSeek Harness 读取 `DSH_HOME`(缺省 `~/.dsh`)及其 `profiles/*`、选中 workspace 下的 `cordis.yml` 和 +`cordis.patch.yml` 中显式 MCP 声明;可识别普通 group 和无目标 insert,但不合成原生 profile 或加载 bundle。 +每个文件独立复用完整 `@deepseek-ai/dsh-mcp-client` 配置,目录中其他 profile 不会被视为当前启用 profile。 +相对 cwd 按选中 workspace 解析;缺少该上下文时不猜测。保留默认 60 秒 tool timeout,并关闭此来源未提供的 OAuth +discovery。动态 YAML tag、需合成的 partial patch、重复声明、作用域/生命周期字段、显式 reconnect policy 和 +`failOnStartupError: true` 显示不支持,不执行插件。OpenBitFun MCP owner 负责导入后的连接、重连与启停。 +PI 本轮未接入 MCP provider。 native ID 优先使用外部 logical name,再使用稳定生态后缀和最小可用数字后缀;超长名称使用 bounded digest,已有条目 永不覆盖。plan fingerprint 同时绑定脱敏 plan、私有投影和当前原生 MCP 配置摘要。apply 会重新发现并重建 plan;来源或 目标内容变化时返回刷新后的脱敏 plan,且不写入;fingerprint 不绑定 coordinator refresh generation,因此内容未变的刷新 不会让 plan stale。配置 service 通过同一 JSON key 的 compare-and-set mutation lane -一次提交全部选中条目或全部不提交,并在 `_openbitfunImport` 中只保留 source-qualified candidate ID 与 behavior version。 -普通 MCP 编辑保留这段 provenance,删除条目时随条目一并移除。 +一次提交全部选中条目或全部不提交,并在 `_openbitfunImport` 中保留 source-qualified candidate ID、behavior version 和可选 sourceId。 +普通 MCP 编辑保留这段 provenance,删除条目时随条目一并移除。旧记录缺少 sourceId 时,Desktop 从 candidate 的完整稳定 ID 与已注册 provider identity 恢复来源显示,不依赖外部文件仍在线。 根 CLI 的 `openbitfun mcp import` 默认只预览,`--apply` 导入全部 eligible 项;重复 `--candidate` 可缩小集合,单一选择可用 `--native-id` 指定目标 ID,`--format json` 输出 versioned plan/result。当前没有 TUI/Mobile/Server/Peer/Remote/ACP/SDK -写入口、导入 journal、tombstone、undo、外部应用回写或插件安装/激活策略;导入后仍由既有 MCP manager 完成复核、编辑、 +写入口、导入 journal、tombstone、外部应用回写或插件安装/激活策略;Desktop 可审阅并撤销选定原生副本,仍由既有 MCP manager 完成复核、编辑、 启用和删除。 -Desktop 的导入卡默认选中当前 plan 中全部 eligible 项,用户可在原卡片内取消个别条目;每项同时显示来源生态和 -用户/项目使用范围,不增加新的向导或主选择器。apply 只发送当前选中 candidate。若并发来源或目标配置变化导致 plan -stale,界面替换为服务端返回的新 plan,并只保留“旧选择与新 eligible candidate 的交集”;新出现的 candidate 不自动 -勾选,避免一次旧确认扩大到用户未见过的内容。取消、完成或切换作用域会清空这份易失选择。 +Desktop 的生态兼容页按所选 Agent 隔离外部内容。查看不会创建原生条目;用户可单项导入、导入当前类别或一键导入该 Agent 的全部可用项,审阅来源、目标 ID +和默认禁用状态后确认。MCP 选择集合一次提交;Skill/Hook 保留逐项成功、跳过和失败结果。Hook 在批量执行期间仅刷新目标 revision,源行为和精确命令必须与审阅内容相同。 +其他 Agent 的 candidate 不进入此次选择。来源或目标变化导致 plan +stale 时,保留同一 candidate 的更新预览并要求重新确认;不自动加入新条目,也不隐式重试写入。切换 Agent、工作区、 +Peer 主机或取消操作会清空详情与确认状态,迟到的旧请求不能更新新作用域。 + +### 0.1 外部内容与原生管理的界面边界 + +生态兼容页独立展示所选 Agent 的 Skill、MCP、Hook 和其他已发现目录项,不嵌入原生 Skill/MCP/Hook 管理页。 +来源设置仅列出所选生态;范围级总开关明确标注其影响,保留既有兼容策略,不把发现动作当作导入。 +ACP 配置嵌入时也只展示所选产品的 clients,并隐藏包含全部产品内容的 JSON 视图。 + +- Skill 使用现有扫描报告按稳定 sourceId 归属筛选,展示该生态的诊断。目录包复制完整依赖,PI/DSH 支持的 Markdown 单文件转换为独立 SKILL.md 包;包内链接依赖明确拒绝。 + 当前 Host 通过扫描报告的可选 importOperationsVersion 协商新导入能力;旧 Host 保留原有目录导入路径。 + 版本 2 支持可选 targetName:确认页可为同名项指定独立目录及调用名称,仅修改副本的 frontmatter name;旧 Host 不接收改名请求。 + add_skill 的 sourceKey 对应已发现来源;复制先在临时目录校验、写入 `.openbitfun-import.json`,再发布到用户/项目原生目录。 + 来源 ID、位置、解析方言、内容摘要和导入 ID 随副本保存;sourceId 仍为 OpenBitFun,原始来源单独用于标签和筛选。 + 发现目录与原生运行时目录分离:未导入的外部 Skill 不进入对话候选、模式技能列表、提示词目录或名称/key 调用入口;旧来源 key 不会自动重定向到副本。 + 本地与远程工作区执行相同过滤,撤销后重新解析即失去调用资格。已批准插件通过独立发布 owner 提供的 Skill 贡献保持原有执行契约。 + 原生用户副本优先于外部用户发现,项目优先于用户的规则保持不变。PI 单文件的缺省名称来自文件名,扫描与实际加载使用相同规则。 + 同源重复导入保持现有副本及用户修改;不同内容或其他来源的同名目标拒绝覆盖。明确重新导入可为内容完全相同的旧副本补齐来源。 + 撤销核对导入 ID 并与发布共用跨进程锁,仅删除审阅的原生副本,外部原文件保留;来源标记损坏时保留文件并显示诊断。 +- 批量操作支持当前 Agent 全部、分类以及勾选项,确认前列出目标和改名结果,完成后保留逐项成功/失败及具体错误。 + 一键导入/撤销弹窗按 Skill、MCP、Hook 分组,限制窗口高度并只滚动清单;进度以实际返回结果数推进(失败也计入已处理),列表刷新期间保留进度与操作区。 + 技能套件的列表、计数、分组编辑及保存后刷新均仅消费原生技能与已导入副本,外部发现项不自动进入套件。 + 批量 MCP 撤销把相同配置指纹下的删除合并为一次 CAS;Hook 撤销仅沿本批成功操作返回的版本推进,其他修改仍触发冲突。 + 弹窗关闭时保留最后一次完整内容直到退场结束,避免标题、正文和页脚先被清空。 +- Hook 按 ecosystemId 和 source key 展示只读目录。Claude Code/Codex 支持的来源经既有 plan/apply 精确命令审阅导入; + 其他形态只展示信息。原生 Hook 页仅管理已导入来源及原生启用设置,后续更新仍需明确确认。 +- 原生 Skill 页只展示 OpenBitFun 自有目录和内置内容(含导入后的副本),按导入来源提供筛选;原生 MCP 页展示来源标签。 + 外部 Command/Tool/Subagent 等尚无快照导入能力的类型展示限制,不用原生管理入口冒充导入实现。 +- 导入前端在不支持写入的 Server/Peer/Remote 环境明确禁用,继续通过现有宿主 API 展示可取得的目录;不回退到控制端文件。 + 已保存的兼容运行策略和用户数据不因发现失败或不支持而重置。 + +“支持发现”不表示支持快照导入或已经可执行。当前五种生态均有 Skill 发现;MCP provider 覆盖 OpenCode、Claude Code、Codex、DSH,PI 暂不支持。 +五种生态的 Hook 目录与同步 command 导入范围分开呈现,后者仅覆盖 Claude Code/Codex 的受支持子集。 +Command/Tool/Subagent 的持续兼容能力继续由原有归属模块控制,此页不提供其快照导入。 +MCP/Hook 的“已导入”只表示已保存,界面提示到原生管理页启用与连接;实际执行仍需满足原生运行时的状态与策略。 ## 1. 产品判断与竞品启示 diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index a797664bdc..9d27a08961 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -329,7 +329,7 @@ "visibility": "documented", "rustPath": "add_skill", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn add_skill( _state: State<'_, AppState>, source_path: String, level: String, workspace_path: Option, ) -> Result", + "signature": "fn add_skill( _state: State<'_, AppState>, source_path: String, level: String, workspace_path: Option, source_key: Option, target_name: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -1777,7 +1777,7 @@ "visibility": "documented", "rustPath": "delete_skill", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn delete_skill( state: State<'_, AppState>, skill_key: String, workspace_path: Option, ) -> Result", + "signature": "fn delete_skill( state: State<'_, AppState>, skill_key: String, workspace_path: Option, expected_import_id: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index d62c549797..5c3883a912 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4230,6 +4230,7 @@ export const forbiddenContentUnderRules = [ allowPaths: [ 'src/crates/adapters/static-hook-support/tests/parser.rs', 'src/crates/adapters/dsh-adapter/src/hook_source.rs', + 'src/crates/adapters/dsh-adapter/src/mcp_source.rs', 'src/crates/adapters/pi-adapter/src/hook_source.rs', 'src/crates/adapters/opencode-adapter/src/hook_source.rs', 'src/crates/adapters/opencode-adapter/src/command_source.rs', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 9cfa963e9f..74d7184a83 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -1195,6 +1195,19 @@ export const externalSourceCorePublicApiEntries = [ 'Desktop external-source configuration host adapter', true, ), + { + symbol: 'ecosystem_for_imported_mcp_candidate', + owner: 'openbitfun-core external MCP provider registration composition', + consumer: 'Desktop MCP list projection for legacy native import receipts', + verification: 'core imported_mcp_legacy_receipt_keeps_registered_origin_without_discovery test', + p0: 'preserve source identity for existing native MCP imports', + contractSlice: contractSlices.externalSourceMcpContract, + wireImpact: false, + rationale: + 'resolve an optional ecosystem id from registered provider identity without discovery, runtime activation, or adapter types in the host API', + exit: + 'remove when legacy import receipts no longer need source identity resolution or an owner replacement preserves offline provenance', + }, ...[ 'unacknowledged_external_ecosystems', 'acknowledge_external_ecosystems', diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index e6259e78d9..1851caaf8a 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -17,6 +17,8 @@ use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MCPServerInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub import_origin: Option, pub id: String, pub name: String, pub status: String, @@ -280,6 +282,17 @@ pub async fn get_mcp_servers(state: State<'_, AppState>) -> Result(value).ok()) + .map(|mut origin| { + if origin.source_id.is_none() { + origin.source_id = openbitfun_core::external_sources::ecosystem_for_imported_mcp_candidate(&origin.source_candidate_id); + } + origin + }), id: config.id.clone(), name: config.name.clone(), status, diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 0cc9622872..0cffcabef0 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -564,8 +564,20 @@ pub async fn get_skill_configs( get_skill_scan_report_for_workspace_input(&state, registry, workspace_path.as_deref()) .await?; - serialize_skill_scan_response(all_skills, include_diagnostics.unwrap_or(false)) - .map_err(|e| format!("Failed to serialize skill configs: {}", e)) + let mut response = + serialize_skill_scan_response(all_skills, include_diagnostics.unwrap_or(false)) + .map_err(|e| format!("Failed to serialize skill configs: {}", e))?; + if let Some(object) = response.as_object_mut() { + let supported = match workspace_path.as_deref() { + Some(path) => !is_remote_path(path).await, + None => true, + }; + object.insert( + "importOperationsVersion".into(), + serde_json::json!(if supported { 2 } else { 0 }), + ); + } + Ok(response) } #[tauri::command] @@ -939,7 +951,52 @@ pub async fn add_skill( source_path: String, level: String, workspace_path: Option, + source_key: Option, + target_name: Option, ) -> Result { + if let Some(source_key) = source_key { + if !matches!(level.as_str(), "user" | "project") { + return Err("Invalid Skill target scope".into()); + } + let workspace = workspace_root_from_input(workspace_path.as_deref()); + if let Some(root) = &workspace { + if is_remote_path(&root.to_string_lossy()).await { + return Err("External Skill import into remote workspaces is not supported".into()); + } + } + let source = SkillRegistry::global() + .find_skill_by_key_for_workspace(&source_key, workspace.as_deref()) + .await + .ok_or("External Skill source changed; refresh before importing")?; + if tokio::fs::canonicalize(&source.path) + .await + .map_err(|error| error.to_string())? + != tokio::fs::canonicalize(&source_path) + .await + .map_err(|error| error.to_string())? + { + return Err("External Skill path does not match the selected source".into()); + } + let paths = get_path_manager_arc(); + let target = if level == "project" { + paths + .project_root(workspace.as_deref().ok_or("No workspace selected")?) + .join("skills") + } else { + paths.user_skills_dir() + }; + openbitfun_core::agentic::tools::implementations::skills::registry::imports::import_copy_as( + source, target, target_name, + ) + .await?; + SkillRegistry::global() + .refresh_for_workspace(workspace.as_deref()) + .await; + return Ok("External Skill imported successfully".into()); + } + if target_name.is_some() { + return Err("Renaming an imported Skill requires its source identity".into()); + } let validation = validate_skill_path(source_path.clone()).await?; if !validation.valid { return Err(validation.error.unwrap_or("Invalid skill path".to_string())); @@ -1033,11 +1090,15 @@ pub async fn delete_skill( state: State<'_, AppState>, skill_key: String, workspace_path: Option, + expected_import_id: Option, ) -> Result { let registry = SkillRegistry::global(); if let Some((remote_root, entry)) = resolve_remote_workspace(&state, workspace_path.as_deref()).await? { + if expected_import_id.is_some() { + return Err("External Skill import undo on remote workspaces is not supported".into()); + } let remote_fs = state .get_remote_file_service_async() .await @@ -1090,7 +1151,9 @@ pub async fn delete_skill( let skill_path = std::path::PathBuf::from(&skill_info.path); - if skill_path.exists() { + if let Some(expected) = expected_import_id { + openbitfun_core::agentic::tools::implementations::skills::registry::imports::remove_imported_copy(&skill_path, &expected).await?; + } else if skill_path.exists() { if let Err(e) = tokio::fs::remove_dir_all(&skill_path).await { return Err(format!("Failed to delete skill folder: {}", e)); } diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index 9570edf251..f26666841a 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -14,7 +14,7 @@ services. | `agent-runtime-ipc` | Non-published private local IPC adapter for the opt-in first-party Shared TUI Runtime; closed interactive operations only | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | | `opencode-adapter` | OpenCode source semantics for user Instructions plus the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | -| `dsh-adapter` | DeepSeek Harness (`dsh`) bundle/profile source projection and static Cordis Hook bridge discovery | [AGENTS.md](dsh-adapter/AGENTS.md) | +| `dsh-adapter` | DeepSeek Harness (`dsh`) bundle/profile projection, static Hook bridge discovery and explicit MCP declarations | [AGENTS.md](dsh-adapter/AGENTS.md) | | `pi-adapter` | PI settings/package extension selection and static native event discovery; no execution | [AGENTS.md](pi-adapter/AGENTS.md) | | `claude-code-adapter` | Runtime-free Claude Code user Instructions, Command, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | | `codex-adapter` | Runtime-free Codex user Instructions, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](codex-adapter/AGENTS.md) | diff --git a/src/crates/adapters/claude-code-adapter/src/mcp_source.rs b/src/crates/adapters/claude-code-adapter/src/mcp_source.rs index db93af7fc0..d38e4123ba 100644 --- a/src/crates/adapters/claude-code-adapter/src/mcp_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/mcp_source.rs @@ -957,24 +957,34 @@ fn prepare_import_projection( definition: ExternalMcpServerDefinition, template: PreparedTransportTemplate, ) -> Result { - if !definition.timeouts.is_empty() { - return Err(ExternalSourceProviderError::new( - "external_mcp.import_setup_required", - "MCP timeout overrides cannot be imported into native configuration", - false, - )); - } - let transport = match template { + let (transport, working_directory, oauth_enabled, environment, headers) = match template { PreparedTransportTemplate::Local { command, args, environment, working_directory, - } if environment.is_empty() && working_directory.is_none() => { - PreparedExternalMcpImportTransport::Local { command, args } + } if collect_environment_reference_names(environment.values()) + .is_ok_and(|refs| refs.is_empty()) => + { + ( + PreparedExternalMcpImportTransport::Local { command, args }, + working_directory, + None, + environment, + BTreeMap::new(), + ) } - PreparedTransportTemplate::Remote { url, headers } if headers.is_empty() => { - PreparedExternalMcpImportTransport::Remote { url } + PreparedTransportTemplate::Remote { url, headers } + if collect_environment_reference_names(headers.values()) + .is_ok_and(|refs| refs.is_empty()) => + { + ( + PreparedExternalMcpImportTransport::Remote { url }, + None, + Some(false), + BTreeMap::new(), + headers, + ) } _ => { return Err(ExternalSourceProviderError::new( @@ -985,9 +995,14 @@ fn prepare_import_projection( } }; let prepared = PreparedExternalMcpImportServer { + environment, + headers, id: definition.id, behavior_version: definition.behavior_version, transport, + working_directory, + timeouts: definition.timeouts, + oauth_enabled, }; prepared.validate().map_err(|_| { ExternalSourceProviderError::new( diff --git a/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs index f4dabbf89a..7394668afb 100644 --- a/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs +++ b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs @@ -248,7 +248,30 @@ fn safe_servers_have_a_native_import_projection_and_unsafe_fields_require_setup( if args == &["--stdio"] )); - for name in ["env", "cwd", "query", "headers"] { + let cwd = snapshot.servers.iter().find(|s| s.name == "cwd").unwrap(); + let prepared_cwd = provider + .prepare_import(&input, &cwd.id, &cwd.behavior_version) + .unwrap(); + assert!(prepared_cwd + .working_directory + .as_ref() + .unwrap() + .ends_with("tools")); + assert_eq!(prepared_remote.oauth_enabled, Some(false)); + for name in ["env", "headers"] { + let server = snapshot.servers.iter().find(|s| s.name == name).unwrap(); + let prepared = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap(); + let values = if name == "env" { + &prepared.environment + } else { + &prepared.headers + }; + assert_eq!(values.values().next().unwrap(), "secret"); + assert!(!format!("{prepared:?}").contains("secret")); + } + for name in ["query"] { let server = snapshot .servers .iter() @@ -396,9 +419,9 @@ fn claude_timeout_controls_execution_and_subsecond_values_are_ignored() { assert_eq!( provider .prepare_import(&input, &docs.id, &docs.behavior_version) - .unwrap_err() - .code, - "external_mcp.import_setup_required" + .unwrap() + .timeouts, + docs.timeouts ); } diff --git a/src/crates/adapters/codex-adapter/src/mcp_source.rs b/src/crates/adapters/codex-adapter/src/mcp_source.rs index cdc439fcf0..f2062de834 100644 --- a/src/crates/adapters/codex-adapter/src/mcp_source.rs +++ b/src/crates/adapters/codex-adapter/src/mcp_source.rs @@ -389,7 +389,6 @@ enum PreparedTransportTemplate { environment: BTreeMap, environment_refs: BTreeMap, working_directory: Option, - working_directory_explicit: bool, }, Remote { url: String, @@ -630,7 +629,6 @@ fn materialize_local( let environment = string_map(object.get("env"), "env", &mut reasons); let environment_refs = environment_refs(object.get("env_vars"), &mut reasons); let timeouts = timeout_overrides(object, &mut reasons); - let working_directory_explicit = object.contains_key("cwd"); let cwd = string_value_optional(object.get("cwd"), "cwd", &mut reasons).map(PathBuf::from); let cwd = cwd.or_else(|| context.workspace_root.clone()); enforce_size( @@ -680,7 +678,6 @@ fn materialize_local( environment, environment_refs, working_directory: cwd, - working_directory_explicit, }, diagnostics, }) @@ -830,7 +827,6 @@ fn unsupported_local( environment: BTreeMap::new(), environment_refs: BTreeMap::new(), working_directory: None, - working_directory_explicit: false, }, diagnostics: Vec::new(), } @@ -849,7 +845,6 @@ fn prepare_transport( mut environment, environment_refs, working_directory, - working_directory_explicit: _, } => { for (key, reference) in environment_refs { environment.insert(key, resolve_environment(&reference)?); @@ -919,41 +914,33 @@ fn prepare_import_projection( definition: ExternalMcpServerDefinition, template: PreparedTransportTemplate, ) -> Result { - if !definition.timeouts.is_empty() { - return Err(ExternalSourceProviderError::new( - "external_mcp.import_setup_required", - "MCP timeout overrides cannot be imported into native configuration", - false, - )); - } - let transport = match template { + let (transport, working_directory, oauth_enabled, environment, headers) = match template { PreparedTransportTemplate::Local { command, args, environment, environment_refs, working_directory, - working_directory_explicit, - } if environment.is_empty() - && environment_refs.is_empty() - && working_directory.is_none() - && !working_directory_explicit => - { - PreparedExternalMcpImportTransport::Local { command, args } - } + } if environment_refs.is_empty() => ( + PreparedExternalMcpImportTransport::Local { command, args }, + working_directory, + None, + environment, + BTreeMap::new(), + ), PreparedTransportTemplate::Remote { url, headers, header_refs, bearer_token_env_var, oauth_enabled, - } if headers.is_empty() - && header_refs.is_empty() - && bearer_token_env_var.is_none() - && oauth_enabled => - { - PreparedExternalMcpImportTransport::Remote { url } - } + } if header_refs.is_empty() && bearer_token_env_var.is_none() => ( + PreparedExternalMcpImportTransport::Remote { url }, + None, + Some(oauth_enabled), + BTreeMap::new(), + headers, + ), _ => { return Err(ExternalSourceProviderError::new( "external_mcp.import_setup_required", @@ -963,9 +950,14 @@ fn prepare_import_projection( } }; let prepared = PreparedExternalMcpImportServer { + environment, + headers, id: definition.id, behavior_version: definition.behavior_version, transport, + working_directory, + timeouts: definition.timeouts, + oauth_enabled, }; prepared.validate().map_err(|_| { ExternalSourceProviderError::new( diff --git a/src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs b/src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs index 2820231f6d..820b3bc5d9 100644 --- a/src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs +++ b/src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs @@ -215,7 +215,7 @@ name = "Ignored display label" } #[test] -fn workspace_implicit_cwd_requires_setup_instead_of_changing_local_behavior() { +fn workspace_implicit_cwd_is_preserved_in_native_import() { let fixture = Fixture::new(); write( fixture.codex_home.join("config.toml"), @@ -229,11 +229,11 @@ args = ["./server.js"] let snapshot = provider.discover(&input).unwrap(); let server = &snapshot.servers[0]; - let error = provider + let prepared = provider .prepare_import(&input, &server.id, &server.behavior_version) - .unwrap_err(); + .unwrap(); - assert_eq!(error.code, "external_mcp.import_setup_required"); + assert_eq!(prepared.working_directory, Some(fixture.workspace.clone())); } #[test] @@ -261,7 +261,7 @@ url = "https://docs.example.test/mcp" } #[test] -fn local_environment_references_and_explicit_cwd_require_setup() { +fn local_environment_references_require_setup_and_explicit_cwd_is_preserved() { let fixture = Fixture::new(); write( fixture.codex_home.join("config.toml"), @@ -282,7 +282,26 @@ cwd = "." let input = fixture.input(); let snapshot = provider.discover(&input).unwrap(); - for name in ["literal_env", "referenced_env", "explicit_cwd"] { + let cwd = snapshot + .servers + .iter() + .find(|s| s.name == "explicit_cwd") + .unwrap(); + let prepared = provider + .prepare_import(&input, &cwd.id, &cwd.behavior_version) + .unwrap(); + assert!(prepared.working_directory.as_ref().unwrap().is_absolute()); + let literal = snapshot + .servers + .iter() + .find(|s| s.name == "literal_env") + .unwrap(); + let prepared = provider + .prepare_import(&input, &literal.id, &literal.behavior_version) + .unwrap(); + assert_eq!(prepared.environment.values().next().unwrap(), "secret"); + assert!(!format!("{prepared:?}").contains("secret")); + for name in ["referenced_env"] { let server = snapshot .servers .iter() @@ -328,6 +347,17 @@ url = "https://user:secret@docs.example.test/mcp" let snapshot = provider.discover(&input).unwrap(); for server in &snapshot.servers { + if server.name == "literal_header" { + let prepared = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap(); + assert_eq!( + prepared.headers.get("X-Secret").map(String::as_str), + Some("secret") + ); + assert!(!format!("{prepared:?}").contains("secret")); + continue; + } let error = provider .prepare_import(&input, &server.id, &server.behavior_version) .unwrap_err(); @@ -416,6 +446,13 @@ tool_timeout_sec = 2.5 .prepare_server(&input, &server.id, &server.behavior_version) .unwrap(); assert_eq!(prepared.timeouts, server.timeouts); + assert_eq!( + provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap() + .timeouts, + server.timeouts + ); } #[test] diff --git a/src/crates/adapters/dsh-adapter/AGENTS.md b/src/crates/adapters/dsh-adapter/AGENTS.md index ac7867c6d2..c95a287db0 100644 --- a/src/crates/adapters/dsh-adapter/AGENTS.md +++ b/src/crates/adapters/dsh-adapter/AGENTS.md @@ -14,6 +14,16 @@ composes a profile or executes plugins. Bridge events remain native-only; unresolved paths, opaque bundle composition, and malformed sources are visible diagnostics. Keep these semantics out of the OpenCode adapter and native registry. +`mcp_source` discovers explicit `@deepseek-ai/dsh-mcp-client` declarations in +home/profile/workspace `cordis.yml` and `cordis.patch.yml`. Each file is an +independent reuse source, not an effective native profile. Literal stdio and +HTTPS Streamable HTTP declarations use the shared MCP provider and import ports. +Preserve launch-relative cwd, the 60-second default tool timeout, and disabled +OAuth discovery. Dynamic YAML, partial patches, scoped lifecycle behavior and +explicit reconnect policies must remain unsupported; never evaluate Cordis or +install packages during discovery. Environment and headers stay private to the +approved runtime preparation and require manual setup for snapshot import. + It does not execute Cordis plugins, install npm packages, or depend on a user-local `dsh` CLI. Execution of dsh bundles belongs to future Plugin Host / external-ACP work, not this adapter boundary. @@ -45,6 +55,7 @@ requires its own consumer evidence before that path is shared. ## Verification +- `cargo test --locked -p openbitfun-dsh-adapter --lib mcp_source::tests` - `cargo test --locked -p openbitfun-dsh-adapter --lib hook_source::tests` - `cargo test --locked -p openbitfun-dsh-adapter --test dsh_source_adapter` - `cargo test --locked -p openbitfun-core --no-default-features --features plugin-runtime --lib plugin_runtime::tests` diff --git a/src/crates/adapters/dsh-adapter/Cargo.toml b/src/crates/adapters/dsh-adapter/Cargo.toml index 459ea3da0e..625ee26421 100644 --- a/src/crates/adapters/dsh-adapter/Cargo.toml +++ b/src/crates/adapters/dsh-adapter/Cargo.toml @@ -23,6 +23,7 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } sha2 = { workspace = true } urlencoding = { workspace = true } +url = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/src/crates/adapters/dsh-adapter/src/lib.rs b/src/crates/adapters/dsh-adapter/src/lib.rs index 7f047c5271..b4a1f9fd53 100644 --- a/src/crates/adapters/dsh-adapter/src/lib.rs +++ b/src/crates/adapters/dsh-adapter/src/lib.rs @@ -7,7 +7,9 @@ //! or depend on a user-local `dsh` CLI. mod hook_source; +mod mcp_source; mod source_adapter; pub use hook_source::{DshHookProvider, DshHookProviderOptions}; +pub use mcp_source::{DshMcpProvider, DshMcpProviderOptions}; pub use source_adapter::load_dsh_package_adapter; diff --git a/src/crates/adapters/dsh-adapter/src/mcp_source.rs b/src/crates/adapters/dsh-adapter/src/mcp_source.rs new file mode 100644 index 0000000000..3c203efc65 --- /dev/null +++ b/src/crates/adapters/dsh-adapter/src/mcp_source.rs @@ -0,0 +1,838 @@ +//! Reuse explicit DSH MCP declarations without evaluating Cordis or selecting a +//! native profile. Each file is an independent source; patches requiring another +//! layer remain unsupported rather than silently producing a partial server. +use openbitfun_product_domains::external_sources::*; +use openbitfun_static_hook_support::{ + read_bounded_text, redacted_executable_preview, resolve_bounded_regular_file, BoundedTextRead, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, +}; + +const PROVIDER: &str = "deepseek-harness.mcp"; +const ECOSYSTEM: &str = "deepseek-harness"; +const MAX_BYTES: usize = 1024 * 1024; +const MAX_FILES: usize = 128; +const MAX_SERVERS: usize = 256; +const DEFAULT_CALL_TIMEOUT: u64 = 60_000; + +#[derive(Clone, Debug)] +pub struct DshMcpProviderOptions { + pub dsh_home: PathBuf, +} + +impl Default for DshMcpProviderOptions { + fn default() -> Self { + Self { + dsh_home: crate::DshHookProviderOptions::default().dsh_home, + } + } +} + +#[derive(Default)] +pub struct DshMcpProvider { + options: DshMcpProviderOptions, +} + +struct SourceFile { + path: PathBuf, + root: PathBuf, + scope: ExternalSourceScope, +} + +struct Materialized { + snapshot: ExternalMcpProviderSnapshot, + transports: BTreeMap, +} + +struct Declaration { + identity: String, + config: Value, + disabled: bool, + unsupported: Option, +} + +impl DshMcpProvider { + pub fn new(options: DshMcpProviderOptions) -> Self { + Self { options } + } + + fn files( + &self, + context: &ExternalSourceContext, + ) -> Result, ExternalSourceProviderError> { + let mut files = Vec::new(); + let mut add = |root: &Path, scope| { + for name in ["cordis.yml", "cordis.patch.yml"] { + files.push(SourceFile { + path: root.join(name), + root: root.to_path_buf(), + scope, + }); + } + }; + add(&self.options.dsh_home, ExternalSourceScope::UserGlobal); + let profiles = self.options.dsh_home.join("profiles"); + match std::fs::read_dir(&profiles) { + Ok(entries) => { + let mut paths = Vec::new(); + for (index, entry) in entries.enumerate() { + if index >= MAX_FILES { + return Err(error( + "source_limit", + "DSH MCP profile directory limit reached", + false, + )); + } + let entry = entry.map_err(|_| { + error("profiles_unreadable", "Could not list DSH profiles", true) + })?; + let kind = entry.file_type().map_err(|_| { + error( + "profiles_unreadable", + "Could not inspect DSH profiles", + true, + ) + })?; + if kind.is_dir() && entry.file_name() != "node_modules" { + paths.push(entry.path()); + } + if paths.len() > MAX_FILES / 2 - 2 { + return Err(error( + "source_limit", + "DSH MCP profile limit reached", + false, + )); + } + } + paths.sort(); + for path in paths { + add(&path, ExternalSourceScope::UserGlobal); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => { + return Err(error( + "profiles_unreadable", + "Could not list DSH profiles", + true, + )) + } + } + if let Some(workspace) = &context.workspace_root { + add(workspace, ExternalSourceScope::Project); + } + Ok(files) + } + + fn materialize( + &self, + input: &ExternalMcpDiscoveryInput, + ) -> Result { + if !self.options.dsh_home.is_absolute() + || input + .context + .workspace_root + .as_ref() + .is_some_and(|p| !p.is_absolute()) + { + return Err(error( + "path_invalid", + "DSH home and workspace paths must be absolute", + false, + )); + } + let mut snapshot = ExternalMcpProviderSnapshot { + provider: self.identity(), + sources: vec![], + servers: vec![], + diagnostics: vec![], + }; + let mut transports = BTreeMap::new(); + let mut seen = BTreeSet::new(); + for file in self.files(&input.context)? { + match std::fs::symlink_metadata(&file.path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => { + return Err(error( + "config_unreadable", + "Could not inspect DSH MCP configuration", + true, + )) + } + Ok(_) => {} + } + let path = resolve_bounded_regular_file(&file.path, &file.root).map_err(|_| { + error( + "config_unreadable", + "DSH MCP source must resolve to a regular file inside its source root", + false, + ) + })?; + if !seen.insert(path.clone()) { + continue; + } + let text = match read_bounded_text(&path, MAX_BYTES) { + Ok(BoundedTextRead::Content(text)) => text, + _ => { + return Err(error( + "config_unreadable", + "DSH MCP source must be readable UTF-8 within 1 MiB", + false, + )) + } + }; + let key = source_key(&path); + let suppressed = input.suppressed_sources.contains(&key); + let yaml: serde_yaml::Value = serde_yaml::from_str(&text) + .map_err(|_| error("config_invalid", "DSH MCP source is not valid YAML", false))?; + let mut declarations = Vec::new(); + let mut incomplete = false; + collect_rows( + &yaml, + "", + false, + None, + 0, + &mut 0, + &mut declarations, + &mut incomplete, + )?; + let mut diagnostics = vec![]; + if incomplete { + diagnostics.push(diagnostic(&key, "composition_required", "DSH patches require profile composition; declarations in this source cannot be activated from a partial configuration")); + } + if !declarations.is_empty() { + diagnostics.push(diagnostic(&key, "declaration_scope", "Explicit MCP declarations are reused independently in OpenBitFun; native DSH profile selection, bundle overlays and reconnect lifecycle are not imported")); + } + let mut identities = BTreeSet::new(); + let mut names = BTreeSet::new(); + let duplicate = declarations.iter().any(|d| { + !identities.insert(d.identity.clone()) + || d.config + .get("serverName") + .and_then(Value::as_str) + .is_some_and(|n| !names.insert(n.to_string())) + }); + for mut declaration in declarations.into_iter().filter(|_| !suppressed) { + if snapshot.servers.len() >= MAX_SERVERS { + return Err(error("server_limit", "DSH MCP server limit reached", false)); + } + if incomplete || duplicate { + declaration.unsupported = Some("DSH patch composition or duplicate declarations must be resolved before this source can be used".into()); + } + let (definition, transport) = materialize_server(input, &key, declaration)?; + transports.insert(definition.id.stable_key(), transport); + snapshot.servers.push(definition); + } + snapshot.sources.push(ExternalSourceRecord { + key: key.clone(), + ecosystem_id: EcosystemId::new(ECOSYSTEM).expect("static id"), + display_name: "DeepSeek Harness MCP declarations".into(), + source_kind: "dsh_mcp_config".into(), + scope: file.scope, + location: path.to_string_lossy().into_owned(), + execution_domain_id: input.context.execution_domain_id.clone(), + health: if incomplete || duplicate { + ExternalSourceHealth::Degraded + } else { + ExternalSourceHealth::Available + }, + content_version: input + .revision_key + .opaque_revision("dsh.mcp.content.v1", [text.as_bytes()]), + diagnostics: diagnostics.clone(), + }); + snapshot.diagnostics.extend(diagnostics); + } + snapshot.validate().map_err(|_| { + error( + "snapshot_invalid", + "DSH MCP catalog could not be validated", + false, + ) + })?; + Ok(Materialized { + snapshot, + transports, + }) + } + + fn current( + &self, + input: &ExternalMcpDiscoveryInput, + id: &SourceQualifiedMcpServerId, + version: &str, + ) -> Result< + (ExternalMcpServerDefinition, PreparedExternalMcpTransport), + ExternalSourceProviderError, + > { + if id.source.provider_id.as_str() != PROVIDER { + return Err(error( + "identity_mismatch", + "MCP server is not owned by DSH", + false, + )); + } + let mut materialized = self.materialize(input)?; + let definition = materialized + .snapshot + .servers + .into_iter() + .find(|d| &d.id == id) + .ok_or_else(|| { + error( + "stale_revision", + "DSH MCP declaration is no longer available", + true, + ) + })?; + if definition.behavior_version != version { + return Err(error( + "stale_revision", + "DSH MCP declaration changed before preparation", + true, + )); + } + if !definition.source_enabled || definition.static_status != ExternalMcpStaticStatus::Ready + { + return Err(error( + "not_activatable", + "DSH MCP declaration is disabled or unsupported", + false, + )); + } + let transport = materialized + .transports + .remove(&id.stable_key()) + .ok_or_else(|| { + error( + "preparation_missing", + "DSH MCP preparation is unavailable", + false, + ) + })?; + Ok((definition, transport)) + } +} + +impl ExternalMcpSourceProvider for DshMcpProvider { + fn identity(&self) -> ExternalMcpProviderIdentity { + ExternalMcpProviderIdentity::new(PROVIDER, ECOSYSTEM, "DeepSeek Harness") + .expect("static id") + } + fn discover( + &self, + input: &ExternalMcpDiscoveryInput, + ) -> Result { + self.materialize(input).map(|m| m.snapshot) + } + fn prepare_server( + &self, + input: &ExternalMcpDiscoveryInput, + id: &SourceQualifiedMcpServerId, + version: &str, + ) -> Result { + let (definition, transport) = self.current(input, id, version)?; + Ok(PreparedExternalMcpServer { + id: id.clone(), + behavior_version: version.into(), + timeouts: definition.timeouts, + transport, + }) + } + fn prepare_import( + &self, + input: &ExternalMcpDiscoveryInput, + id: &SourceQualifiedMcpServerId, + version: &str, + ) -> Result { + let (definition, transport) = self.current(input, id, version)?; + let (transport, working_directory, oauth_enabled, environment, headers) = match transport { + PreparedExternalMcpTransport::Local { + command, + args, + environment, + working_directory, + } => ( + PreparedExternalMcpImportTransport::Local { command, args }, + working_directory, + None, + environment + .into_iter() + .map(|(key, value)| (key, value.expose().to_owned())) + .collect(), + BTreeMap::new(), + ), + PreparedExternalMcpTransport::Remote { + url, + headers, + oauth_enabled, + } => ( + PreparedExternalMcpImportTransport::Remote { url }, + None, + Some(oauth_enabled), + BTreeMap::new(), + headers + .into_iter() + .map(|(key, value)| (key, value.expose().to_owned())) + .collect(), + ), + }; + let prepared = PreparedExternalMcpImportServer { + id: id.clone(), + behavior_version: version.into(), + transport, + working_directory, + timeouts: definition.timeouts, + oauth_enabled, + environment, + headers, + }; + prepared.validate().map_err(|_| { + import_setup("DSH declaration cannot be represented by the current MCP import contract") + })?; + Ok(prepared) + } + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + let mut roots = vec![ExternalWatchRoot { + path: self.options.dsh_home.clone(), + recursive: true, + }]; + if let Some(path) = &context.workspace_root { + roots.push(ExternalWatchRoot { + path: path.clone(), + recursive: false, + }); + } + roots + } +} + +#[allow(clippy::too_many_arguments)] +fn collect_rows( + value: &serde_yaml::Value, + prefix: &str, + parent_disabled: bool, + parent_reason: Option<&str>, + depth: usize, + count: &mut usize, + declarations: &mut Vec, + incomplete: &mut bool, +) -> Result<(), ExternalSourceProviderError> { + if depth > 16 { + return Err(error( + "depth_limit", + "DSH MCP source nesting limit reached", + false, + )); + } + let serde_yaml::Value::Sequence(rows) = value else { + return Err(error( + "config_invalid", + "DSH Cordis source must contain a list of rows", + false, + )); + }; + for (index, raw) in rows.iter().enumerate() { + *count += 1; + if *count > 2048 { + return Err(error( + "row_limit", + "DSH MCP source row limit reached", + false, + )); + } + if matches!(raw, serde_yaml::Value::Tagged(_)) { + *incomplete = true; + continue; + } + let Some(map) = raw.as_mapping() else { + *incomplete = true; + continue; + }; + let get = |key: &str| map.get(serde_yaml::Value::String(key.into())); + let named = get("name").and_then(plain_string).unwrap_or_default(); + let row_id = get("id") + .and_then(plain_string) + .map(str::to_owned) + .unwrap_or_else(|| index.to_string()); + let identity = format!("{prefix}/{row_id}"); + let disabled = + parent_disabled || matches!(get("disabled"), Some(serde_yaml::Value::Bool(true))); + let mut reason = parent_reason.map(str::to_owned); + if get("disabled").is_some_and(|v| !matches!(v, serde_yaml::Value::Bool(_))) { + reason = Some("DSH disabled state must be a literal boolean".into()); + } + if let Some(insert) = get("insert") { + // An unqualified append is self-contained. Targeted insert/move/patch + // operations need the owning native profile and cannot be guessed. + if map.len() != 1 { + *incomplete = true; + } + collect_rows( + insert, + &identity, + disabled, + reason.as_deref(), + depth + 1, + count, + declarations, + incomplete, + )?; + continue; + } + if matches!(get("group"), Some(serde_yaml::Value::Bool(true))) { + if map.keys().any(|k| { + !plain_string(k).is_some_and(|s| ["id", "group", "config", "disabled"].contains(&s)) + }) { + reason = Some("DSH scoped groups require native Cordis composition".into()); + } + if let Some(config) = get("config") { + collect_rows( + config, + &identity, + disabled, + reason.as_deref(), + depth + 1, + count, + declarations, + incomplete, + )?; + } else { + *incomplete = true; + } + continue; + } + if named.is_empty() { + *incomplete = true; + continue; + } + if !["@deepseek-ai/dsh-mcp-client", "dsh-mcp-client"].contains(&named) { + continue; + } + if map.keys().any(|k| { + !plain_string(k).is_some_and(|s| ["id", "name", "config", "disabled"].contains(&s)) + }) { + reason = + Some("DSH MCP row contains unsupported Cordis lifecycle or scope fields".into()); + } + let config = get("config").unwrap_or(&serde_yaml::Value::Null); + if contains_tag(raw, 0) { + reason = Some("Dynamic Cordis YAML tags are not evaluated during MCP discovery".into()); + } + let config = serde_json::to_value(config).unwrap_or(Value::Null); + declarations.push(Declaration { + identity, + config, + disabled, + unsupported: reason, + }); + } + Ok(()) +} + +fn plain_string(value: &serde_yaml::Value) -> Option<&str> { + match value { + serde_yaml::Value::String(s) => Some(s), + _ => None, + } +} +fn contains_tag(value: &serde_yaml::Value, depth: usize) -> bool { + if depth > 32 { + return true; + } + match value { + serde_yaml::Value::Tagged(_) => true, + serde_yaml::Value::Sequence(items) => items.iter().any(|v| contains_tag(v, depth + 1)), + serde_yaml::Value::Mapping(map) => map + .iter() + .any(|(k, v)| contains_tag(k, depth + 1) || contains_tag(v, depth + 1)), + _ => false, + } +} + +fn materialize_server( + input: &ExternalMcpDiscoveryInput, + source: &SourceKey, + declaration: Declaration, +) -> Result<(ExternalMcpServerDefinition, PreparedExternalMcpTransport), ExternalSourceProviderError> +{ + let mut reason = declaration.unsupported; + let empty = Map::new(); + let config = declaration.config.as_object().unwrap_or_else(|| { + reason.get_or_insert("DSH MCP config must be an object".into()); + &empty + }); + let raw_name = config + .get("serverName") + .and_then(Value::as_str) + .unwrap_or_default(); + let valid_name = !raw_name.is_empty() + && raw_name.len() <= 32 + && raw_name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); + let name = if valid_name { + raw_name.to_string() + } else { + reason.get_or_insert( + "DSH serverName must contain 1 to 32 ASCII letters, numbers, underscores or hyphens" + .into(), + ); + format!("invalid-{}", digest(declaration.identity.as_bytes())) + }; + let id = SourceQualifiedMcpServerId::new( + source.clone(), + format!("mcp-{}", digest(declaration.identity.as_bytes())), + ) + .expect("hashed id"); + let remote = config.get("transport").and_then(Value::as_str) == Some("streamable-http"); + let allowed: &[&str] = if remote { + &[ + "transport", + "serverName", + "url", + "headers", + "toolCallTimeoutMs", + "failOnStartupError", + ] + } else { + &[ + "transport", + "serverName", + "command", + "args", + "env", + "cwd", + "toolCallTimeoutMs", + "failOnStartupError", + ] + }; + if config.keys().any(|k| !allowed.contains(&k.as_str())) { + reason.get_or_insert( + "DSH MCP configuration has unsupported fields (including explicit reconnect policies)" + .into(), + ); + } + if config + .get("failOnStartupError") + .is_some_and(|v| v != &Value::Bool(false)) + { + reason.get_or_insert("DSH failOnStartupError must be false; native profile startup cannot be controlled by an imported MCP server".into()); + } + let timeout = match config.get("toolCallTimeoutMs") { + None => DEFAULT_CALL_TIMEOUT, + Some(v) => match v.as_u64().filter(|n| (1..=2_147_483_647).contains(n)) { + Some(n) => n, + None => { + reason.get_or_insert( + "DSH toolCallTimeoutMs must be a positive bounded integer".into(), + ); + DEFAULT_CALL_TIMEOUT + } + }, + }; + if declaration.config.to_string().len() > 64 * 1024 { + reason.get_or_insert("DSH MCP declaration exceeds the 64 KiB runtime limit".into()); + } + let mut definition = ExternalMcpServerDefinition { + id, + provenance: vec![source.clone()], + name, + transport: if remote { + ExternalMcpTransportKind::StreamableHttp + } else { + ExternalMcpTransportKind::LocalStdio + }, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: vec![], + environment_reference_names: vec![], + remote_url_preview: None, + header_names: vec![], + timeouts: ExternalMcpTimeouts { + execution_ms: Some(timeout), + ..Default::default() + }, + source_enabled: !declaration.disabled, + behavior_version: String::new(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let transport = if remote { + let raw_url = text(config.get("url"), &mut reason).unwrap_or_default(); + let mut headers = string_map(config.get("headers"), &mut reason); + let mut keys = BTreeSet::new(); + if headers.keys().any(|k| !keys.insert(k.to_ascii_lowercase())) { + reason.get_or_insert("DSH MCP header names must be unique ignoring case".into()); + headers.clear(); + } + let preview = url::Url::parse(&raw_url) + .ok() + .filter(|url| url.scheme() == "https" && url.host_str().is_some()); + definition.remote_url_preview = Some(if let Some(mut url) = preview { + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + reason.get_or_insert( + "DSH MCP URL must not contain user information or a fragment".into(), + ); + } + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_path("/"); + url.set_query(None); + url.set_fragment(None); + url.to_string() + } else { + reason.get_or_insert("DSH remote MCP requires a valid HTTPS endpoint".into()); + "https://unsupported.invalid/".into() + }); + definition.header_names = headers.keys().cloned().collect(); + PreparedExternalMcpTransport::Remote { + url: raw_url, + headers: headers + .into_iter() + .map(|(k, v)| (k, SecretValue::new(v))) + .collect(), + oauth_enabled: false, + } + } else { + if config.get("transport").and_then(Value::as_str) != Some("stdio") { + reason.get_or_insert("DSH transport must be stdio or streamable-http".into()); + } + let command = text(config.get("command"), &mut reason) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| { + reason.get_or_insert("DSH MCP command must be a non-empty string".into()); + String::new() + }); + let args = match config.get("args") { + None => vec![], + Some(Value::Array(args)) if args.len() <= 256 => args + .iter() + .filter_map(|v| text(Some(v), &mut reason)) + .collect(), + _ => { + reason.get_or_insert("DSH MCP args must be an array of at most 256 strings".into()); + vec![] + } + }; + let environment = string_map(config.get("env"), &mut reason); + let cwd = match config.get("cwd") { + None => None, + value => text(value, &mut reason).filter(|s| !s.is_empty()), + }; + // cwd in DSH's MCP SDK is launch-relative, never relative to the profile + // YAML directory. A selected workspace supplies that launch context. + let working_directory = match cwd.map(PathBuf::from) { + Some(path) if path.is_absolute() => Some(path), + Some(path) => input + .context + .workspace_root + .as_ref() + .map(|root| root.join(path)) + .or_else(|| { + reason.get_or_insert( + "Relative DSH MCP cwd requires a selected launch workspace".into(), + ); + None + }), + None => input.context.workspace_root.clone(), + }; + definition.command_preview = Some(redacted_executable_preview(&command)); + definition.argument_count = args.len(); + definition.working_directory = working_directory + .as_ref() + .map(|_| "".into()); + definition.environment_keys = environment.keys().cloned().collect(); + PreparedExternalMcpTransport::Local { + command, + args, + environment: environment + .into_iter() + .map(|(k, v)| (k, SecretValue::new(v))) + .collect(), + working_directory, + } + }; + definition.static_status = if let Some(reason) = reason { + ExternalMcpStaticStatus::Unsupported { reason } + } else if declaration.disabled { + ExternalMcpStaticStatus::DisabledBySource + } else { + ExternalMcpStaticStatus::Ready + }; + let encoded = declaration.config.to_string(); + let cwd = match &transport { + PreparedExternalMcpTransport::Local { + working_directory, .. + } => working_directory + .as_ref() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), + _ => String::new(), + }; + let status = serde_json::to_string(&definition.static_status).unwrap_or_default(); + definition.behavior_version = input.revision_key.opaque_revision( + "dsh.mcp.behavior.v1", + [encoded.as_bytes(), cwd.as_bytes(), status.as_bytes()], + ); + Ok((definition, transport)) +} + +fn text(value: Option<&Value>, reason: &mut Option) -> Option { + match value { + Some(Value::String(s)) if !s.contains('\0') && s.len() <= 64 * 1024 => Some(s.clone()), + _ => { + reason.get_or_insert("DSH MCP values must be bounded literal strings".into()); + None + } + } +} +fn string_map(value: Option<&Value>, reason: &mut Option) -> BTreeMap { + let Some(value) = value else { + return BTreeMap::new(); + }; + let Some(map) = value.as_object().filter(|m| m.len() <= 128) else { + reason.get_or_insert( + "DSH MCP environment and headers must be objects with at most 128 entries".into(), + ); + return BTreeMap::new(); + }; + map.iter() + .filter_map(|(k, v)| { + if k.is_empty() || k.len() > 128 || k.chars().any(char::is_control) { + reason.get_or_insert("DSH MCP environment or header name is invalid".into()); + return None; + } + text(Some(v), reason).map(|v| (k.clone(), v)) + }) + .collect() +} +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes))[..24].to_string() +} +fn source_key(path: &Path) -> SourceKey { + SourceKey::new( + PROVIDER, + format!("dsh_mcp-{}", digest(path.to_string_lossy().as_bytes())), + ) + .expect("hashed id") +} +fn error(suffix: &str, message: &str, transient: bool) -> ExternalSourceProviderError { + ExternalSourceProviderError::new(format!("dsh.mcp.{suffix}"), message, transient) +} +fn import_setup(message: &str) -> ExternalSourceProviderError { + ExternalSourceProviderError::new("external_mcp.import_setup_required", message, false) +} +fn diagnostic(source: &SourceKey, suffix: &str, message: &str) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic::warning(format!("dsh.mcp.{suffix}"), message, Some(source.clone())) + .with_asset_kind(ExternalSourceAssetKind::Mcp) +} + +#[cfg(test)] +#[path = "mcp_source_tests.rs"] +mod tests; diff --git a/src/crates/adapters/dsh-adapter/src/mcp_source_tests.rs b/src/crates/adapters/dsh-adapter/src/mcp_source_tests.rs new file mode 100644 index 0000000000..8927467150 --- /dev/null +++ b/src/crates/adapters/dsh-adapter/src/mcp_source_tests.rs @@ -0,0 +1,189 @@ +use super::*; +use std::fs; +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + home: PathBuf, + workspace: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home/.dsh"); + let workspace = temp.path().join("project"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + Self { + _temp: temp, + home, + workspace, + } + } + fn input(&self) -> ExternalMcpDiscoveryInput { + ExternalMcpDiscoveryInput { + context: ExternalSourceContext { + workspace_root: Some(self.workspace.clone()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + }, + suppressed_sources: BTreeSet::new(), + revision_key: ExternalMcpRevisionKey::new([7; 32]), + } + } + fn provider(&self) -> DshMcpProvider { + DshMcpProvider::new(DshMcpProviderOptions { + dsh_home: self.home.clone(), + }) + } + fn write(&self, relative: &str, body: &str) { + let path = self.home.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); + } +} + +#[test] +fn discovers_home_profile_and_workspace_and_preserves_launch_context_on_import() { + let fixture = Fixture::new(); + fixture.write("cordis.patch.yml", "- insert:\n - id: local\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: docs, transport: stdio, command: docs-server, args: [--stdio], cwd: tools, toolCallTimeoutMs: 1250}\n"); + fixture.write("profiles/dev/cordis.yml", "- id: remote\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: web, transport: streamable-http, url: 'https://example.test/mcp'}\n"); + fs::write(fixture.workspace.join("cordis.yml"), "- id: project\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: project, transport: stdio, command: project-server}\n").unwrap(); + let provider = fixture.provider(); + let input = fixture.input(); + let snapshot = provider.discover(&input).unwrap(); + assert_eq!(snapshot.sources.len(), 3); + assert_eq!(snapshot.servers.len(), 3); + for server in &snapshot.servers { + assert_eq!(server.static_status, ExternalMcpStaticStatus::Ready); + let prepared = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap(); + prepared.validate().unwrap(); + match server.name.as_str() { + "docs" => { + assert_eq!( + prepared.working_directory, + Some(fixture.workspace.join("tools")) + ); + assert_eq!(prepared.timeouts.execution_ms, Some(1250)); + assert_eq!( + prepared.transport, + PreparedExternalMcpImportTransport::Local { + command: "docs-server".into(), + args: vec!["--stdio".into()] + } + ); + } + "web" => { + assert_eq!(prepared.oauth_enabled, Some(false)); + assert_eq!(prepared.timeouts.execution_ms, Some(60_000)); + } + "project" => assert_eq!(prepared.working_directory, Some(fixture.workspace.clone())), + name => panic!("Unexpected server: {name}"), + } + } +} + +#[test] +fn secrets_stay_private_and_configuration_changes_invalidate_preparation() { + let fixture = Fixture::new(); + let body = "- id: remote\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: docs, transport: streamable-http, url: 'https://example.test/private-token?key=query-secret', headers: {Authorization: 'Bearer header-secret'}}\n"; + fixture.write("cordis.yml", body); + let provider = fixture.provider(); + let input = fixture.input(); + let snapshot = provider.discover(&input).unwrap(); + let serialized = serde_json::to_string(&snapshot).unwrap(); + for secret in ["private-token", "query-secret", "header-secret"] { + assert!(!serialized.contains(secret)); + } + let server = &snapshot.servers[0]; + assert_eq!(server.static_status, ExternalMcpStaticStatus::Ready); + assert!(provider + .prepare_server(&input, &server.id, &server.behavior_version) + .is_ok()); + assert_eq!( + provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap_err() + .code, + "external_mcp.import_setup_required" + ); + fixture.write( + "cordis.yml", + &body.replace("header-secret", "changed-secret"), + ); + let changed = provider.discover(&input).unwrap(); + assert_ne!(changed.servers[0].behavior_version, server.behavior_version); + assert_eq!( + provider + .prepare_server(&input, &server.id, &server.behavior_version) + .unwrap_err() + .code, + "dsh.mcp.stale_revision" + ); +} + +#[test] +fn disabled_suppressed_and_unsupported_declarations_cannot_prepare() { + let fixture = Fixture::new(); + fixture.write("cordis.yml", "- id: group\n group: true\n disabled: true\n config:\n - id: disabled\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: disabled, transport: stdio, command: docs}\n- id: dynamic\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: dynamic, transport: stdio, command: !js 'process.exit(1)'}\n- id: reconnect\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: reconnect, transport: stdio, command: docs, reconnect: {enabled: false}}\n"); + let provider = fixture.provider(); + let mut input = fixture.input(); + let snapshot = provider.discover(&input).unwrap(); + assert_eq!(snapshot.servers.len(), 3); + for server in &snapshot.servers { + assert_ne!(server.static_status, ExternalMcpStaticStatus::Ready); + assert!(provider + .prepare_server(&input, &server.id, &server.behavior_version) + .is_err()); + assert!(provider + .prepare_import(&input, &server.id, &server.behavior_version) + .is_err()); + } + input + .suppressed_sources + .insert(snapshot.sources[0].key.clone()); + let suppressed = provider.discover(&input).unwrap(); + assert_eq!(suppressed.sources.len(), 1); + assert!(suppressed.servers.is_empty()); +} + +#[test] +fn partial_patches_duplicate_names_and_unresolved_cwd_fail_closed() { + let fixture = Fixture::new(); + let declaration = "- id: docs\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: docs, transport: stdio, command: docs-server, cwd: tools}\n"; + let provider = fixture.provider(); + let mut input = fixture.input(); + for body in [ + format!("{declaration}- id: docs\n config: {{command: replaced}}\n"), + format!( + "{declaration}{}", + declaration.replace("id: docs", "id: other") + ), + ] { + fixture.write("cordis.yml", &body); + let snapshot = provider.discover(&input).unwrap(); + assert_eq!(snapshot.sources[0].health, ExternalSourceHealth::Degraded); + assert!(snapshot + .servers + .iter() + .all(|s| matches!(s.static_status, ExternalMcpStaticStatus::Unsupported { .. }))); + } + fixture.write("cordis.yml", declaration); + input.context.workspace_root = None; + assert!(matches!( + provider.discover(&input).unwrap().servers[0].static_status, + ExternalMcpStaticStatus::Unsupported { .. } + )); +} + +#[test] +fn malformed_and_oversized_files_produce_explicit_errors() { + let fixture = Fixture::new(); + let provider = fixture.provider(); + for body in ["[invalid: yaml".to_string(), "x".repeat(MAX_BYTES + 1)] { + fixture.write("cordis.yml", &body); + assert!(provider.discover(&fixture.input()).is_err()); + } +} diff --git a/src/crates/adapters/opencode-adapter/src/mcp_source.rs b/src/crates/adapters/opencode-adapter/src/mcp_source.rs index 4c1b35f634..ce5ed8e4da 100644 --- a/src/crates/adapters/opencode-adapter/src/mcp_source.rs +++ b/src/crates/adapters/opencode-adapter/src/mcp_source.rs @@ -337,7 +337,6 @@ enum PreparedTransportTemplate { args: Vec, environment: BTreeMap, working_directory: Option, - working_directory_explicit: bool, }, Remote { url: String, @@ -431,7 +430,6 @@ fn materialize_server( args: Vec::new(), environment: BTreeMap::new(), working_directory: None, - working_directory_explicit: false, }, }) } @@ -475,7 +473,6 @@ fn materialize_local_server( reason.get_or_insert(error.clone()); } let environment_reference_names = environment_reference_names.unwrap_or_default(); - let working_directory_explicit = object.contains_key("cwd"); let cwd = match object.get("cwd") { None => context .workspace_root @@ -545,7 +542,6 @@ fn materialize_local_server( args, environment, working_directory: cwd, - working_directory_explicit, }, }) } @@ -659,7 +655,6 @@ fn resolve_runtime_values( args, environment, working_directory, - working_directory_explicit: _, } => { let command = expand_environment_references(&command)?; let args = args @@ -744,30 +739,30 @@ fn prepare_import_projection( definition: ExternalMcpServerDefinition, template: PreparedTransportTemplate, ) -> Result { - if !definition.timeouts.is_empty() { - return Err(ExternalSourceProviderError::new( - "external_mcp.import_setup_required", - "MCP timeout overrides cannot be imported into native configuration", - false, - )); - } - let transport = match template { + let (transport, working_directory, oauth_enabled, environment, headers) = match template { PreparedTransportTemplate::Local { command, args, environment, - working_directory: _, - working_directory_explicit, - } if environment.is_empty() && !working_directory_explicit => { - PreparedExternalMcpImportTransport::Local { command, args } - } + working_directory, + } if environment.values().all(|value| !value.contains("{env:")) => ( + PreparedExternalMcpImportTransport::Local { command, args }, + working_directory, + None, + environment, + BTreeMap::new(), + ), PreparedTransportTemplate::Remote { url, headers, oauth_enabled, - } if headers.is_empty() && oauth_enabled => { - PreparedExternalMcpImportTransport::Remote { url } - } + } if headers.values().all(|value| !value.contains("{env:")) => ( + PreparedExternalMcpImportTransport::Remote { url }, + None, + Some(oauth_enabled), + BTreeMap::new(), + headers, + ), _ => { return Err(ExternalSourceProviderError::new( "external_mcp.import_setup_required", @@ -777,9 +772,14 @@ fn prepare_import_projection( } }; let prepared = PreparedExternalMcpImportServer { + environment, + headers, id: definition.id, behavior_version: definition.behavior_version, transport, + working_directory, + timeouts: definition.timeouts, + oauth_enabled, }; prepared.validate().map_err(|_| { ExternalSourceProviderError::new( diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs index 65214c3906..e1e01a0f97 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs @@ -341,6 +341,34 @@ fn unsafe_user_servers_require_setup_instead_of_copying_opaque_fields() { let snapshot = provider.discover(&input).unwrap(); for server in &snapshot.servers { + if server.name == "env" || server.name == "headers" { + let prepared = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap(); + let values = if server.name == "env" { + &prepared.environment + } else { + &prepared.headers + }; + assert_eq!(values.values().next().unwrap(), "secret"); + assert!(!format!("{prepared:?}").contains("secret")); + continue; + } + if server.name == "cwd" || server.name == "no-oauth" { + let prepared = provider + .prepare_import(&input, &server.id, &server.behavior_version) + .unwrap(); + if server.name == "cwd" { + assert!(prepared + .working_directory + .as_ref() + .unwrap() + .ends_with("tools")); + } else { + assert_eq!(prepared.oauth_enabled, Some(false)); + } + continue; + } let error = provider .prepare_import(&input, &server.id, &server.behavior_version) .unwrap_err(); @@ -554,9 +582,9 @@ fn opencode_timeout_applies_to_all_mcp_lifecycle_phases() { assert_eq!( provider .prepare_import(&input, &server.id, &server.behavior_version) - .unwrap_err() - .code, - "external_mcp.import_setup_required" + .unwrap() + .timeouts, + server.timeouts ); } diff --git a/src/crates/assembly/AGENTS.md b/src/crates/assembly/AGENTS.md index 9a0522ac80..b9c87f98df 100644 --- a/src/crates/assembly/AGENTS.md +++ b/src/crates/assembly/AGENTS.md @@ -54,3 +54,11 @@ integration, or stable product-domain contracts. and should be owned by app or adapter code when possible. - Interface crates may call assembly APIs, but adapters and services must not depend on assembly. + +## Focused Verification + +For external-source discovery scheduling, queueing, and deferred completion: + +```bash +cargo test --locked -p openbitfun-external-sources --lib refresh::tests +``` diff --git a/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs b/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs index 6ead9339ba..2f8264623f 100644 --- a/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs +++ b/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs @@ -349,7 +349,7 @@ async fn load_discovered_review_skill( &markdown, info.level, true, - &info.source_slot, + info.parser_source_slot(), ) .map_err(|error| OpenBitFunError::tool(error.to_string()))?; data.key = info.key.clone(); @@ -438,6 +438,9 @@ fn xml_escape(value: &str) -> String { mod tests { use super::*; use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::tools::implementations::skills::registry::imports::{ + import_copy_as, remove_imported_copy, + }; use crate::agentic::WorkspaceBinding; use std::collections::HashMap; use std::path::PathBuf; @@ -458,6 +461,29 @@ mod tests { } } + async fn import_review_skill(root: &std::path::Path, source_key: &str) -> SkillInfo { + let registry = get_skill_registry(); + let source = registry + .find_skill_by_key_for_workspace(source_key, Some(root)) + .await + .expect("discovered review skill"); + import_copy_as(source, root.join(".openbitfun/skills"), None) + .await + .expect("imported review skill"); + registry + .get_all_skills_for_workspace(Some(root)) + .await + .into_iter() + .find(|skill| { + skill.is_native() + && skill + .import_origin + .as_ref() + .is_some_and(|origin| origin.source_key == source_key) + }) + .expect("native review skill") + } + #[test] fn compatible_skill_uses_directory_convention_not_metadata_name() { assert!(is_compatible_review_skill("code-review-breaking-changes")); @@ -531,7 +557,7 @@ mod tests { } #[tokio::test] - async fn catalog_loads_claude_review_skill_with_source_semantics() { + async fn catalog_requires_import_preserves_claude_semantics_and_revokes_on_undo() { let temp = tempfile::tempdir().expect("temporary workspace"); let skill_dir = temp .path() @@ -545,13 +571,46 @@ mod tests { ) .expect("skill markdown"); let context = local_tool_context(temp.path().to_path_buf()); + let source_key = "project::claude::code-review-claude"; + assert!(review_capability_catalog(&context) + .await + .iter() + .all(|descriptor| !descriptor.key().contains("code-review-claude"))); + assert!(load_review_skill(&context, source_key).await.is_err()); + let imported = import_review_skill(temp.path(), source_key).await; let descriptor = review_capability_catalog(&context) .await .into_iter() - .find(|descriptor| descriptor.key().contains("code-review-claude")); + .find(|descriptor| descriptor.key() == format!("skill:{}", imported.key)) + .expect("imported review skill descriptor"); + + let resolved = + resolve_review_capability(&context, descriptor.key(), descriptor.fingerprint()) + .await + .expect("resolved imported Claude guidance"); + assert_eq!( + resolved.guidance, + "Review $target for Claude compatibility." + ); + assert!(load_review_skill(&context, source_key).await.is_err()); - assert!(descriptor.is_some()); + remove_imported_copy( + std::path::Path::new(&imported.path), + &imported.import_origin.as_ref().unwrap().import_id, + ) + .await + .expect("undo imported review skill"); + assert!(review_capability_catalog(&context) + .await + .iter() + .all(|candidate| candidate.key() != descriptor.key())); + assert!( + resolve_review_capability(&context, descriptor.key(), descriptor.fingerprint()) + .await + .is_err() + ); + assert!(skill_dir.join("SKILL.md").is_file()); } #[tokio::test] @@ -568,15 +627,29 @@ mod tests { "---\nname: Policy review\ndescription: Check policy changes\n---\nReview policy-sensitive behavior.\n", ) .expect("skill markdown"); + std::fs::write( + skill_dir.join("agents").join("openai.yaml"), + "policy:\n allow_implicit_invocation: true\n", + ) + .expect("initial policy"); let context = local_tool_context(temp.path().to_path_buf()); + let imported = + import_review_skill(temp.path(), "project::codex::code-review-policy-change").await; let descriptor = review_capability_catalog(&context) .await .into_iter() .find(|descriptor| descriptor.key().contains("code-review-policy-change")) .expect("review skill descriptor"); + assert!( + resolve_review_capability(&context, descriptor.key(), descriptor.fingerprint()) + .await + .is_ok() + ); std::fs::write( - skill_dir.join("agents").join("openai.yaml"), + PathBuf::from(&imported.path) + .join("agents") + .join("openai.yaml"), "policy:\n allow_implicit_invocation: false\n", ) .expect("updated policy"); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index ddcade56a7..dbbb795d15 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -433,7 +433,9 @@ Use the remote project skill. } } - struct ClaudeRemoteFs; + struct ClaudeRemoteFs { + imported: bool, + } #[async_trait] impl WorkspaceFileSystem for ClaudeRemoteFs { @@ -442,12 +444,21 @@ Use the remote project skill. } async fn read_file_text(&self, path: &str) -> anyhow::Result { - if path == "/remote/project/.claude/skills/remote-review/SKILL.md" { + if path == "/remote/project/.claude/skills/remote-review/SKILL.md" + || (self.imported + && path == "/remote/project/.openbitfun/skills/remote-review/SKILL.md") + { return Ok( "---\ndescription: Review a remote target.\narguments: target focus\nmodel: opus\n---\n\nReview $target for $focus.\nContext: !`git diff`\n" .to_string(), ); } + if self.imported + && path + == "/remote/project/.openbitfun/skills/remote-review/.openbitfun-import.json" + { + return Ok(json!({ "schemaVersion": 1, "importId": "remote-import", "sourceKey": "project::claude::remote-review", "sourcePath": "/remote/project/.claude/skills/remote-review", "sourceId": "claude-code", "sourceLabel": "Claude Code", "sourceSlot": "claude", "fingerprint": "fixture" }).to_string()); + } anyhow::bail!("not found: {}", path) } @@ -460,21 +471,29 @@ Use the remote project skill. } async fn is_file(&self, path: &str) -> anyhow::Result { - Ok(path == "/remote/project/.claude/skills/remote-review/SKILL.md") + Ok(path == "/remote/project/.claude/skills/remote-review/SKILL.md" || (self.imported && matches!(path, + "/remote/project/.openbitfun/skills/remote-review/SKILL.md" | "/remote/project/.openbitfun/skills/remote-review/.openbitfun-import.json"))) } async fn is_dir(&self, path: &str) -> anyhow::Result { Ok(matches!( path, "/remote/project/.claude/skills" | "/remote/project/.claude/skills/remote-review" - )) + ) || (self.imported + && matches!( + path, + "/remote/project/.openbitfun/skills" + | "/remote/project/.openbitfun/skills/remote-review" + ))) } async fn read_dir(&self, path: &str) -> anyhow::Result> { - if path == "/remote/project/.claude/skills" { + if path == "/remote/project/.claude/skills" + || (self.imported && path == "/remote/project/.openbitfun/skills") + { return Ok(vec![WorkspaceDirEntry { name: "remote-review".to_string(), - path: "/remote/project/.claude/skills/remote-review".to_string(), + path: format!("{path}/remote-review"), is_dir: true, is_symlink: false, modified: None, @@ -500,6 +519,37 @@ Use the remote project skill. } } + async fn import_test_skill( + root: &std::path::Path, + source_key: &str, + target_name: Option<&str>, + ) -> crate::agentic::tools::implementations::skills::types::SkillInfo { + let registry = SkillRegistry::global(); + let source = registry + .find_skill_by_key_for_workspace(source_key, Some(root)) + .await + .unwrap(); + crate::agentic::tools::implementations::skills::registry::imports::import_copy_as( + source, + root.join(".openbitfun/skills"), + target_name.map(str::to_string), + ) + .await + .unwrap(); + registry + .get_all_skills_for_workspace(Some(root)) + .await + .into_iter() + .find(|skill| { + skill.is_native() + && skill + .import_origin + .as_ref() + .is_some_and(|origin| origin.source_key == source_key) + }) + .unwrap() + } + #[test] fn skill_schema_exposes_optional_arguments() { let schema = SkillTool::new().input_schema(); @@ -509,7 +559,7 @@ Use the remote project skill. } #[tokio::test] - async fn stable_key_loads_a_shadowed_nested_skill_without_changing_name_resolution() { + async fn stable_key_requires_import_without_changing_original_name_resolution() { let temp = tempfile::tempdir().unwrap(); for (directory, body) in [ (".openbitfun/skills/same", "default body"), @@ -526,9 +576,22 @@ Use the remote project skill. .unwrap(); } let context = local_context(temp.path().to_path_buf()); + assert!(SkillTool::new() + .call_impl( + &json!({ "command": "project::codex::nested/same" }), + &context + ) + .await + .is_err()); + let imported = import_test_skill( + temp.path(), + "project::codex::nested/same", + Some("chosen-copy"), + ) + .await; for (command, expected) in [ ("source-collision-regression", "default body"), - ("project::codex::nested/same", "chosen body"), + (imported.key.as_str(), "chosen body"), ] { let results = SkillTool::new() .call_impl(&json!({ "command": command }), &context) @@ -546,19 +609,13 @@ Use the remote project skill. let mut document = load_project_mode_skills_document_local(temp.path()) .await .unwrap(); - set_mode_skill_disabled_in_document( - &mut document, - "agent", - "project::codex::nested/same", - true, - ) - .unwrap(); + set_mode_skill_disabled_in_document(&mut document, "agent", &imported.key, true).unwrap(); save_project_mode_skills_document_local(temp.path(), &document) .await .unwrap(); assert!(SkillRegistry::global() .find_and_load_skill_by_key_for_workspace( - "project::codex::nested/same", + &imported.key, Some(temp.path()), Some("agent") ) @@ -566,6 +623,112 @@ Use the remote project skill. .is_err()); } + #[tokio::test] + async fn discovered_external_skills_require_import_for_listing_and_execution_and_revoke_on_undo( + ) { + use crate::agentic::tools::implementations::skills::registry::imports::remove_imported_copy; + let temp = tempfile::tempdir().unwrap(); + let registry = SkillRegistry::global(); + let mut sources = Vec::new(); + for ecosystem in [ + "claude", "codex", "cursor", "opencode", "agents", "dsh", "pi", + ] { + let name = format!("import-boundary-{ecosystem}"); + let path = temp.path().join(format!(".{ecosystem}/skills/{name}")); + fs::create_dir_all(&path).unwrap(); + fs::write(path.join("SKILL.md"), format!("---\nname: {name}\ndescription: Import boundary fixture.\n---\nImported content for {ecosystem}.\n")).unwrap(); + sources.push((name, path)); + } + let report = registry + .get_skill_scan_report_for_workspace(Some(temp.path())) + .await; + let external = report + .skills + .iter() + .filter(|skill| skill.name.starts_with("import-boundary-")) + .collect::>(); + assert_eq!(external.len(), sources.len()); + for mode in [None, Some("agent")] { + let mut context = local_context(temp.path().to_path_buf()); + context.agent_type = mode.map(str::to_string); + assert!(!registry + .get_resolved_skills_for_workspace(Some(temp.path()), mode) + .await + .iter() + .any(|skill| skill.name.starts_with("import-boundary-"))); + assert!(!registry + .get_resolved_skills_xml_for_workspace(Some(temp.path()), mode) + .await + .iter() + .any(|xml| xml.contains(", agent_type: Option<&str>, ) -> Vec { + // Discovery alone never publishes a skill into the native runtime. + // Approved plugin contributions are added by their owner below. + candidates.retain(|candidate| candidate.info.is_native()); #[cfg(feature = "opencode-plugin-host")] { let plugin_roots = crate::plugin_capability_publication::skill_roots_for_agent( @@ -1420,11 +1430,12 @@ impl SkillRegistry { async fn apply_mode_filters_for_remote_workspace( &self, - candidates: Vec, + mut candidates: Vec, fs: &dyn WorkspaceFileSystem, remote_root: &str, agent_type: Option<&str>, ) -> Vec { + candidates.retain(|candidate| candidate.info.is_native()); let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; let candidates = Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); @@ -1450,8 +1461,11 @@ impl SkillRegistry { candidates: Vec, agent_type: Option<&str>, ) -> OpenBitFunResult { + if let Some(error) = Self::unimported_skill_error(&candidates, skill_name) { + return Err(error); + } match resolve_default_hidden_builtin_for_explicit_invocation( - skill_name, candidates, agent_type, + skill_name, candidates.into_iter().filter(|candidate| candidate.info.is_native()).collect(), agent_type, ) { ExplicitSkillInvocationResolution::Found(info) => Ok(info), ExplicitSkillInvocationResolution::NotFound => Err(OpenBitFunError::tool(format!( @@ -1467,6 +1481,26 @@ impl SkillRegistry { } } + fn unimported_skill_error( + candidates: &[SkillCandidate], + identifier: &str, + ) -> Option { + let matches = |candidate: &&SkillCandidate| { + candidate.info.key == identifier || candidate.info.name == identifier + }; + if candidates + .iter() + .filter(matches) + .any(|candidate| candidate.info.is_native()) + { + return None; + } + candidates.iter().find(matches).map(|candidate| OpenBitFunError::tool(format!( + "Skill '{}' is only discovered from '{}'; import it into OpenBitFun through Ecosystem Compatibility before invoking it.", + identifier, candidate.info.source_label + ))) + } + async fn find_skill_info_for_explicit_invocation_workspace( &self, skill_name: &str, @@ -1671,7 +1705,11 @@ impl SkillRegistry { let scan = self .scan_skill_candidates_with_diagnostics_for_workspace(workspace_root) .await; - let candidates = scan.candidates; + let candidates: Vec<_> = scan + .candidates + .into_iter() + .filter(|candidate| candidate.info.is_native()) + .collect(); let all_skills = sort_skills(annotate_shadowed_skills(candidates.clone())); let user_overrides = load_user_mode_skill_overrides(mode_id) .await @@ -1724,7 +1762,11 @@ impl SkillRegistry { let scan = self .scan_skill_candidates_with_diagnostics_for_remote_workspace(fs, remote_root) .await; - let candidates = scan.candidates; + let candidates: Vec<_> = scan + .candidates + .into_iter() + .filter(|candidate| candidate.info.is_native()) + .collect(); let all_skills = sort_skills(annotate_shadowed_skills(candidates.clone())); let user_overrides = load_user_mode_skill_overrides(mode_id) .await @@ -1794,11 +1836,11 @@ impl SkillRegistry { let content = Self::read_local_skill_markdown(&info).await?; let mut data = Self::parse_skill_markdown( - info.path.clone(), + info.parser_path(), &content, info.level, true, - &info.source_slot, + info.parser_source_slot(), ) .map_err(|error| OpenBitFunError::tool(error.to_string()))?; data.path = info.path; @@ -1820,6 +1862,7 @@ impl SkillRegistry { let candidates = self .scan_skill_candidates_for_workspace(workspace_root) .await; + let unimported = Self::unimported_skill_error(&candidates, skill_key); let filtered = self .apply_mode_filters_for_workspace(candidates, workspace_root, agent_type) .await; @@ -1828,6 +1871,9 @@ impl SkillRegistry { .map(|candidate| candidate.info) .find(|skill| skill.key == skill_key) .ok_or_else(|| { + if let Some(error) = unimported { + return error; + } OpenBitFunError::tool(format!( "Skill key '{}' was not found or is disabled for this mode", skill_key @@ -1837,11 +1883,11 @@ impl SkillRegistry { let content = Self::read_local_skill_markdown(&info).await?; let mut data = Self::parse_skill_markdown( - info.path.clone(), + info.parser_path(), &content, info.level, true, - &info.source_slot, + info.parser_source_slot(), ) .map_err(|error| OpenBitFunError::tool(error.to_string()))?; data.path = info.path; @@ -1872,11 +1918,11 @@ impl SkillRegistry { let content = Self::read_skill_md_for_remote_merge(&info, fs).await?; let mut data = Self::parse_skill_markdown( - info.path.clone(), + info.parser_path(), &content, info.level, true, - &info.source_slot, + info.parser_source_slot(), ) .map_err(|error| OpenBitFunError::tool(error.to_string()))?; data.path = info.path; @@ -1899,6 +1945,7 @@ impl SkillRegistry { let candidates = self .scan_skill_candidates_for_remote_workspace(fs, remote_root) .await; + let unimported = Self::unimported_skill_error(&candidates, skill_key); let filtered = self .apply_mode_filters_for_remote_workspace(candidates, fs, remote_root, agent_type) .await; @@ -1907,6 +1954,9 @@ impl SkillRegistry { .map(|candidate| candidate.info) .find(|skill| skill.key == skill_key) .ok_or_else(|| { + if let Some(error) = unimported { + return error; + } OpenBitFunError::tool(format!( "Skill key '{}' was not found or is disabled for this mode", skill_key @@ -1915,11 +1965,11 @@ impl SkillRegistry { let content = Self::read_skill_md_for_remote_merge(&info, fs).await?; let mut data = Self::parse_skill_markdown( - info.path.clone(), + info.parser_path(), &content, info.level, true, - &info.source_slot, + info.parser_source_slot(), ) .map_err(|error| OpenBitFunError::tool(error.to_string()))?; data.path = info.path; @@ -2484,7 +2534,8 @@ mod remote_scan_tests { assert!(group[1].info.allow_implicit_invocation); } assert!(skills[0].priority < skills[13].priority); - assert!(fs.calls.load(Ordering::SeqCst) <= 100); + // 92 discovery/policy calls plus one provenance existence check for each native package. + assert!(fs.calls.load(Ordering::SeqCst) <= 92 + 13); assert_eq!(fs.active.load(Ordering::SeqCst), 0); assert!(fs.peak.load(Ordering::SeqCst) > 1); assert!(fs.peak.load(Ordering::SeqCst) <= super::REMOTE_SKILL_SCAN_CONCURRENCY); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry/discovery.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry/discovery.rs index f589b9fd47..35e5d1aed5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry/discovery.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry/discovery.rs @@ -44,8 +44,13 @@ fn flat_skill_data( slot: &str, ) -> Result { let stem = filename.strip_suffix(".md").unwrap_or(filename); - let mut data = - SkillRegistry::parse_skill_markdown(directory.to_string(), content, level, false, slot)?; + let mut data = SkillRegistry::parse_skill_markdown( + format!("{}/{stem}", directory.trim_end_matches(['/', '\\'])), + content, + level, + false, + slot, + )?; data.path = directory.to_string(); data.dir_name = stem.to_string(); data.entry_file = Some(filename.to_string()); @@ -141,54 +146,95 @@ impl SkillRegistry { match fs.is_file(&skill_md).await { Ok(true) => { match fs.read_file_text_bounded(&skill_md, MAX_SKILL_BYTES).await { - Ok(Some(content)) => match Self::parse_skill_markdown( - path.clone(), - &content, - SkillLocation::Project, - false, - entry.slot, - ) { - Ok(mut data) => { - for warning in &data.compatibility_warnings { - scan.diagnostics.push(diagnostic( - &skill_md, - entry.source_id, - warning, - )); + Ok(Some(content)) => { + let marker = format!("{path}/{}", imports::IMPORT_MARKER); + let import_origin = if entry.source_id == OPENBITFUN_SKILL_SOURCE_ID + { + let marker_content = match fs.exists(&marker).await { + Ok(false) => Ok(None), + Ok(true) => fs.read_file_text_bounded(&marker, 16 * 1024).await + .and_then(|text| text.map(Some).ok_or_else(|| anyhow::anyhow!("Skill import record exceeds the size limit"))), + Err(error) => Err(error), + }; + match marker_content { + Ok(Some(text)) => match imports::parse_import_origin(&text) + { + Ok(origin) => Some(origin), + Err(error) => { + scan.diagnostics.push(diagnostic( + &marker, + entry.source_id, + error, + )); + None + } + }, + Ok(None) => None, + Err(error) => { + scan.diagnostics.push(diagnostic( + &marker, + entry.source_id, + error, + )); + None + } } - if let Some(error) = - Self::apply_remote_openai_policy(&mut data, fs, &path).await - { - scan.diagnostics.push(diagnostic( - format!("{path}/agents/openai.yaml"), + } else { + None + }; + match Self::parse_skill_markdown( + path.clone(), + &content, + SkillLocation::Project, + false, + import_origin + .as_ref() + .map_or(entry.slot, |origin| origin.source_slot.as_str()), + ) { + Ok(mut data) => { + for warning in &data.compatibility_warnings { + scan.diagnostics.push(diagnostic( + &skill_md, + entry.source_id, + warning, + )); + } + if let Some(error) = + Self::apply_remote_openai_policy(&mut data, fs, &path) + .await + { + scan.diagnostics.push(diagnostic( + format!("{path}/agents/openai.yaml"), + entry.source_id, + error, + )); + } + let mut candidate = SkillCandidate::from_data( + data, + entry.slot, entry.source_id, - error, - )); + entry.source_label, + PROJECT_SKILL_KEY_PREFIX, + entry.priority, + false, + ); + set_nested_key( + &mut candidate, + path.strip_prefix(&format!("{}/", entry.path)) + .expect("discovered child"), + ); + candidate.info.installation_source = + installation_sources.get(&candidate.info.name).cloned(); + candidate.info.import_origin = import_origin; + scan.candidates.push(candidate); } - let mut candidate = SkillCandidate::from_data( - data, - entry.slot, + Err(error) => scan.diagnostics.push(diagnostic( + &skill_md, entry.source_id, - entry.source_label, - PROJECT_SKILL_KEY_PREFIX, - entry.priority, - false, - ); - set_nested_key( - &mut candidate, - path.strip_prefix(&format!("{}/", entry.path)) - .expect("discovered child"), - ); - candidate.info.installation_source = - installation_sources.get(&candidate.info.name).cloned(); - scan.candidates.push(candidate); + error, + )), } - Err(error) => scan.diagnostics.push(diagnostic( - &skill_md, - entry.source_id, - error, - )), - }, + } Ok(None) => scan.diagnostics.push(diagnostic( &skill_md, entry.source_id, @@ -427,12 +473,31 @@ impl SkillRegistry { "SKILL.md exceeds the discovery size limit", )); } else { + let import_origin = if entry.source_id == OPENBITFUN_SKILL_SOURCE_ID + && !entry.is_builtin + { + match imports::read_import_origin(&path).await { + Ok(origin) => origin, + Err(error) => { + scan.diagnostics.push(diagnostic( + path.join(imports::IMPORT_MARKER).to_string_lossy(), + entry.source_id, + error, + )); + None + } + } + } else { + None + }; match Self::parse_skill_markdown( path.to_string_lossy().into_owned(), &content, entry.level, false, - entry.slot, + import_origin + .as_ref() + .map_or(entry.slot, |origin| origin.source_slot.as_str()), ) { Ok(mut data) => { for warning in &data.compatibility_warnings { @@ -471,6 +536,7 @@ impl SkillRegistry { set_nested_key(&mut candidate, &relative_dir); candidate.info.installation_source = installation_sources.get(&candidate.info.name).cloned(); + candidate.info.import_origin = import_origin; scan.candidates.push(candidate); } Err(error) => scan.diagnostics.push(diagnostic( @@ -759,9 +825,8 @@ mod tests { .find(|candidate| candidate.info.source_id == "pi") .unwrap(); assert_eq!(pi.info.key, "project::pi::review.md"); - // PI derives a missing name from the containing directory, including - // for a flat Markdown file (not from its filename). - assert_eq!(pi.info.name, "skills"); + // Flat entries use the same filename stem locally and remotely. + assert_eq!(pi.info.name, "review"); assert_eq!(pi.info.path, "/remote/.pi/skills"); let content = SkillRegistry::read_skill_md_for_remote_merge(&pi.info, &FlatRemote) .await diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry/imports.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry/imports.rs new file mode 100644 index 0000000000..8d7b117dbf --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry/imports.rs @@ -0,0 +1,629 @@ +//! Native Skill copy provenance and publication, owned alongside registry filesystem IO. +use super::*; +use openbitfun_agent_runtime::skills::SkillImportOrigin; +use sha2::{Digest, Sha256}; + +pub const IMPORT_MARKER: &str = ".openbitfun-import.json"; + +pub fn parse_import_origin(content: &str) -> Result { + let origin: SkillImportOrigin = + serde_json::from_str(content).map_err(|_| "Invalid Skill import record")?; + if origin.schema_version != 1 + || origin.import_id.is_empty() + || origin.source_key.is_empty() + || origin.source_id.is_empty() + || origin.source_path.is_empty() + || origin.fingerprint.is_empty() + { + return Err("Unsupported or incomplete Skill import record".into()); + } + Ok(origin) +} + +pub async fn read_import_origin(path: &Path) -> Result, String> { + use tokio::io::AsyncReadExt; + let file = match fs::File::open(path.join(IMPORT_MARKER)).await { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let mut content = String::new(); + file.take(16 * 1024 + 1) + .read_to_string(&mut content) + .await + .map_err(|error| error.to_string())?; + if content.len() > 16 * 1024 { + return Err("Skill import record is too large".into()); + } + parse_import_origin(&content).map(Some) +} + +/// Remove only the reviewed native copy; serialize with publication of new copies. +pub async fn remove_imported_copy(path: &Path, expected_import_id: &str) -> Result<(), String> { + let root = path.parent().ok_or("Invalid Skill target")?; + let _lock = openbitfun_services_core::json_store::JsonFileStore + .acquire_cross_process_lock(&root.join(".skill-import-lock")) + .await + .map_err(|error| error.to_string())?; + let metadata = fs::symlink_metadata(path) + .await + .map_err(|error| error.to_string())?; + if is_symlink_or_reparse(&metadata) || !metadata.is_dir() { + return Err("Imported Skill target changed; refresh before removing".into()); + } + let origin = read_import_origin(path) + .await? + .ok_or("Skill import identity is missing; refresh before removing")?; + if origin.import_id != expected_import_id { + return Err("Skill import identity changed; refresh before removing".into()); + } + fs::remove_dir_all(path) + .await + .map_err(|error| error.to_string()) +} + +// Package traversal never follows links inside a bundle. A linked package root is +// resolved once; copying arbitrary linked files would no longer be a standalone copy. +fn package_files(root: &Path) -> Result, String> { + let mut files = Vec::new(); + let mut pending = vec![(root.to_path_buf(), 0usize)]; + let mut count = 0; + while let Some((directory, depth)) = pending.pop() { + if depth > 32 { + return Err("Skill package nesting limit exceeded".into()); + } + for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + count += 1; + if count > 32768 { + return Err("Skill package entry limit exceeded".into()); + } + let metadata = + std::fs::symlink_metadata(entry.path()).map_err(|error| error.to_string())?; + if is_symlink_or_reparse(&metadata) { + return Err( + "Skill package contains linked dependencies; import a standalone copy".into(), + ); + } + if metadata.is_dir() { + pending.push((entry.path(), depth + 1)); + } else if metadata.is_file() { + files.push(entry.path()); + } else { + return Err("Skill package contains an unsupported file type".into()); + } + } + } + files.retain(|path| path.file_name().is_none_or(|name| name != IMPORT_MARKER)); + files.sort(); + Ok(files) +} + +pub fn package_fingerprint(root: &Path) -> Result { + let mut digest = Sha256::new(); + for path in package_files(root)? { + let relative = path.strip_prefix(root).map_err(|error| error.to_string())?; + let relative = relative.to_string_lossy().replace('\\', "/"); + digest.update((relative.len() as u64).to_le_bytes()); + digest.update(relative.as_bytes()); + let mut file = std::fs::File::open(&path).map_err(|error| error.to_string())?; + digest.update( + file.metadata() + .map_err(|error| error.to_string())? + .len() + .to_le_bytes(), + ); + let mut buffer = [0u8; 64 * 1024]; + loop { + use std::io::Read; + let count = file.read(&mut buffer).map_err(|error| error.to_string())?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + } + Ok(format!("{:x}", digest.finalize())) +} + +/// Idempotently imports one discovered external package. Existing distinct user +/// content is never overwritten. Identical legacy copies can acquire provenance. +pub async fn import_copy( + source: SkillInfo, + target_root: PathBuf, +) -> Result { + import_copy_as(source, target_root, None).await +} + +/// A reviewed alias changes both the native directory and invocation name. +/// The source package and every existing native copy remain untouched. +pub async fn import_copy_as( + source: SkillInfo, + target_root: PathBuf, + target_name: Option, +) -> Result { + if let Some(name) = &target_name { + let reserved = name + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + if name.is_empty() + || name.len() > 100 + || !name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + || matches!( + reserved.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) + { + return Err( + "Invalid Skill import name: use 1–100 letters, digits, hyphens or underscores" + .into(), + ); + } + } + if source.is_builtin || source.source_id == OPENBITFUN_SKILL_SOURCE_ID { + return Err("Expected an external Skill source".into()); + } + fs::create_dir_all(&target_root) + .await + .map_err(|error| error.to_string())?; + let _lock = openbitfun_services_core::json_store::JsonFileStore + .acquire_cross_process_lock(&target_root.join(".skill-import-lock")) + .await + .map_err(|error| error.to_string())?; + let result = tokio::task::spawn_blocking(move || { + use std::fs as disk; + let source_root = disk::canonicalize(&source.path).map_err(|error| error.to_string())?; + let folder = target_name.as_ref().unwrap_or(&source.dir_name); + if folder.is_empty() + || folder == "." + || folder == ".." + || folder.contains(['/', '\\', '\0']) + { + return Err("Invalid Skill directory name".into()); + } + disk::create_dir_all(&target_root).map_err(|error| error.to_string())?; + let target = target_root.join(folder); + let staging_root = target_root + .parent() + .ok_or("Invalid Skill target root")? + .join("skill-import-staging"); + disk::create_dir_all(&staging_root).map_err(|error| error.to_string())?; + let import_id = uuid::Uuid::new_v4().to_string(); + let staging = staging_root.join(&import_id); + disk::create_dir(&staging).map_err(|error| error.to_string())?; + let prepared = (|| { + if let Some(entry) = source + .entry_file + .as_deref() + .filter(|entry| *entry != "SKILL.md") + { + if entry.contains(['/', '\\']) { + return Err("Invalid Skill entry file".into()); + } + if is_symlink_or_reparse( + &disk::symlink_metadata(source_root.join(entry)) + .map_err(|error| error.to_string())?, + ) { + return Err("Skill entry is linked; import a standalone copy".into()); + } + disk::copy(source_root.join(entry), staging.join("SKILL.md")) + .map_err(|error| error.to_string())?; + } else { + for file in package_files(&source_root)? { + let destination = staging.join( + file.strip_prefix(&source_root) + .map_err(|error| error.to_string())?, + ); + if let Some(parent) = destination.parent() { + disk::create_dir_all(parent).map_err(|error| error.to_string())?; + } + disk::copy(file, destination).map_err(|error| error.to_string())?; + } + } + let mut markdown = disk::read_to_string(staging.join("SKILL.md")) + .map_err(|error| error.to_string())?; + if let Some(name) = &target_name { + let content = markdown.trim_start_matches('\u{feff}'); + let mut lines = content.split_inclusive('\n'); + let first = lines.next().ok_or("Skill frontmatter is missing")?; + if first.trim() != "---" { + return Err("Skill frontmatter is missing".into()); + } + let mut end = first.len(); + let mut header = String::new(); + let mut closed = false; + for line in lines { + end += line.len(); + if line.trim() == "---" { + closed = true; + break; + } + header.push_str(line); + } + if !closed { + return Err("Skill frontmatter is incomplete".into()); + } + let mut metadata: serde_yaml::Mapping = + serde_yaml::from_str(&header).map_err(|error| error.to_string())?; + metadata.insert( + serde_yaml::Value::String("name".into()), + serde_yaml::Value::String(name.clone()), + ); + markdown = format!( + "---\n{}---\n{}", + serde_yaml::to_string(&metadata).map_err(|error| error.to_string())?, + &content[end..] + ); + disk::write(staging.join("SKILL.md"), &markdown) + .map_err(|error| error.to_string())?; + } + SkillRegistry::parse_skill_markdown( + target.to_string_lossy().into_owned(), + &markdown, + source.level, + false, + &source.source_slot, + ) + .map_err(|error| error.to_string())?; + let fingerprint = package_fingerprint(&staging)?; + let origin = SkillImportOrigin { + schema_version: 1, + import_id, + source_key: source.key, + source_path: source.path, + source_id: source.source_id, + source_label: source.source_label, + source_slot: source.source_slot, + fingerprint, + }; + if target.exists() { + if is_symlink_or_reparse( + &disk::symlink_metadata(&target).map_err(|error| error.to_string())?, + ) { + return Err("Skill target is a link; refusing to modify its destination".into()); + } + let marker = target.join(IMPORT_MARKER); + if marker.exists() { + let existing = parse_import_origin( + &disk::read_to_string(&marker).map_err(|error| error.to_string())?, + )?; + if existing.source_key == origin.source_key + && existing.source_path == origin.source_path + { + return Ok(existing); + } + return Err("Skill target belongs to a different import".into()); + } + if package_fingerprint(&target)? != origin.fingerprint { + return Err("Skill target already exists with different content".into()); + } + // A confirmed re-import can repair a byte-identical legacy copy. + use std::io::Write; + let mut file = disk::OpenOptions::new() + .write(true) + .create_new(true) + .open(marker) + .map_err(|error| error.to_string())?; + file.write_all( + &serde_json::to_vec_pretty(&origin).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + return Ok(origin); + } + disk::write( + staging.join(IMPORT_MARKER), + serde_json::to_vec_pretty(&origin).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + // Publish the complete package in a single filesystem operation. + disk::rename(&staging, &target).map_err(|error| error.to_string())?; + Ok(origin) + })(); + // This UUID staging directory is created by this operation, never a user input path. + let _ = disk::remove_dir_all(&staging); + prepared + }) + .await + .map_err(|error| error.to_string())?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root( + path: PathBuf, + source_id: &'static str, + slot: &'static str, + priority: usize, + ) -> SkillRootEntry { + SkillRootEntry { + path, + level: SkillLocation::User, + slot, + source_id, + source_label: source_id, + priority, + is_builtin: false, + } + } + + async fn source(temp: &Path) -> SkillInfo { + let path = temp.join("external/demo"); + fs::create_dir_all(path.join("scripts")).await.unwrap(); + // Claude permits a directory-name fallback; native parsing alone rejects it. + fs::write(path.join("SKILL.md"), "---\ndescription: Imported workflow\nargument-hint: target\n---\nRun scripts/tool.py for $ARGUMENTS.\n").await.unwrap(); + fs::write(path.join("scripts/tool.py"), "print('fixture')\n") + .await + .unwrap(); + SkillRegistry::scan_skills_in_dir(&root( + temp.join("external"), + "claude-code", + "home.claude", + 1, + )) + .await + .candidates + .remove(0) + .info + } + + #[tokio::test] + async fn imported_package_preserves_dialect_assets_identity_and_runtime_selection() { + let temp = tempfile::tempdir().unwrap(); + let source = source(temp.path()).await; + let target = temp.path().join("native"); + let origin = import_copy(source.clone(), target.clone()).await.unwrap(); + let native = SkillRegistry::scan_skills_in_dir(&root( + target.clone(), + "openbitfun", + OPENBITFUN_USER_SKILL_SLOT, + 0, + )) + .await + .candidates + .remove(0); + assert_eq!(native.info.source_id, "openbitfun"); + assert_eq!(native.info.import_origin.as_ref().unwrap(), &origin); + assert_eq!(native.info.argument_hint.as_deref(), Some("target")); + let content = SkillRegistry::read_local_skill_markdown(&native.info) + .await + .unwrap(); + let loaded = SkillRegistry::parse_skill_markdown( + native.info.path.clone(), + &content, + SkillLocation::User, + true, + native.info.parser_source_slot(), + ) + .unwrap(); + assert_eq!(loaded.name, "demo"); + assert!(content.contains("scripts/tool.py")); + assert!(target.join("demo/scripts/tool.py").is_file()); + let mut external = SkillRegistry::scan_skills_in_dir(&root( + temp.path().join("external"), + "claude-code", + "home.claude", + 1, + )) + .await + .candidates; + external.push(native); + let selected = resolve_visible_skills(external); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].source_id, "openbitfun"); + fs::write(target.join("demo/scripts/tool.py"), "user edit") + .await + .unwrap(); + assert_eq!( + import_copy(source, target.clone()).await.unwrap().import_id, + origin.import_id + ); + assert_eq!( + fs::read_to_string(target.join("demo/scripts/tool.py")) + .await + .unwrap(), + "user edit" + ); + } + + #[tokio::test] + async fn confirmed_legacy_reimport_adopts_identical_copy_and_rejects_different_content() { + let temp = tempfile::tempdir().unwrap(); + let source = source(temp.path()).await; + let target = temp.path().join("native"); + let first = import_copy(source.clone(), target.clone()).await.unwrap(); + fs::remove_file(target.join("demo").join(IMPORT_MARKER)) + .await + .unwrap(); + let repaired = import_copy(source.clone(), target.clone()).await.unwrap(); + assert_ne!(first.import_id, repaired.import_id); + assert_eq!(first.fingerprint, repaired.fingerprint); + fs::remove_file(target.join("demo").join(IMPORT_MARKER)) + .await + .unwrap(); + fs::write(target.join("demo/SKILL.md"), "user content") + .await + .unwrap(); + assert!(import_copy(source, target.clone()) + .await + .unwrap_err() + .contains("different content")); + assert_eq!( + fs::read_to_string(target.join("demo/SKILL.md")) + .await + .unwrap(), + "user content" + ); + assert!(!target.join("demo").join(IMPORT_MARKER).exists()); + } + + #[tokio::test] + async fn flat_pi_entry_imports_only_its_own_markdown_as_native_package() { + let temp = tempfile::tempdir().unwrap(); + let external = temp.path().join("external"); + fs::create_dir(&external).await.unwrap(); + fs::write( + external.join("demo.md"), + "---\ndescription: Flat workflow\n---\nHello\n", + ) + .await + .unwrap(); + fs::write( + external.join("other.md"), + "---\nname: other\ndescription: Other\n---\nPrivate\n", + ) + .await + .unwrap(); + let source = SkillRegistry::scan_skills_in_dir(&root(external, "pi", "home.pi", 1)) + .await + .candidates + .into_iter() + .find(|c| c.info.dir_name == "demo") + .unwrap() + .info; + assert_eq!(source.name, "demo"); + let content = SkillRegistry::read_local_skill_markdown(&source) + .await + .unwrap(); + let loaded = SkillRegistry::parse_skill_markdown( + source.parser_path(), + &content, + SkillLocation::User, + true, + source.parser_source_slot(), + ) + .unwrap(); + assert_eq!(loaded.name, "demo"); + let target = temp.path().join("native"); + import_copy(source, target.clone()).await.unwrap(); + assert!(target.join("demo/SKILL.md").is_file()); + assert!(!target.join("demo/other.md").exists()); + } + + #[tokio::test] + async fn reviewed_alias_keeps_both_skills_callable_and_undo_preserves_originals() { + let temp = tempfile::tempdir().unwrap(); + let source = source(temp.path()).await; + let original = fs::read_to_string(Path::new(&source.path).join("SKILL.md")) + .await + .unwrap(); + let target = temp.path().join("native"); + import_copy(source.clone(), target.clone()).await.unwrap(); + fs::write(target.join("demo/scripts/tool.py"), "existing user edit") + .await + .unwrap(); + let alias = import_copy_as( + source.clone(), + target.clone(), + Some("demo-claude-code".into()), + ) + .await + .unwrap(); + let candidates = SkillRegistry::scan_skills_in_dir(&root( + target.clone(), + "openbitfun", + OPENBITFUN_USER_SKILL_SLOT, + 0, + )) + .await + .candidates; + let resolved = resolve_visible_skills(candidates); + assert_eq!(resolved.len(), 2); + let renamed = resolved + .iter() + .find(|skill| skill.name == "demo-claude-code") + .unwrap(); + assert_eq!(renamed.dir_name, "demo-claude-code"); + assert_eq!( + renamed.import_origin.as_ref().unwrap().source_key, + source.key + ); + assert_eq!(renamed.argument_hint.as_deref(), Some("target")); + assert_eq!( + fs::read_to_string(Path::new(&source.path).join("SKILL.md")) + .await + .unwrap(), + original + ); + assert_eq!( + import_copy_as( + source.clone(), + target.clone(), + Some("demo-claude-code".into()) + ) + .await + .unwrap() + .import_id, + alias.import_id + ); + remove_imported_copy(&target.join("demo-claude-code"), &alias.import_id) + .await + .unwrap(); + assert_eq!( + fs::read_to_string(target.join("demo/scripts/tool.py")) + .await + .unwrap(), + "existing user edit" + ); + assert!(Path::new(&source.path).join("SKILL.md").is_file()); + for name in ["../escape", "CON", "bad/name", "", "name."] { + assert!( + import_copy_as(source.clone(), target.clone(), Some(name.into())) + .await + .is_err() + ); + } + } + + #[tokio::test] + async fn undo_keeps_source_and_rejects_a_receipt_for_a_replaced_copy() { + let temp = tempfile::tempdir().unwrap(); + let source = source(temp.path()).await; + let target = temp.path().join("native"); + let first = import_copy(source.clone(), target.clone()).await.unwrap(); + remove_imported_copy(&target.join("demo"), &first.import_id) + .await + .unwrap(); + assert!(Path::new(&source.path).join("SKILL.md").is_file()); + let second = import_copy(source, target.clone()).await.unwrap(); + assert!(remove_imported_copy(&target.join("demo"), &first.import_id) + .await + .is_err()); + assert_eq!( + read_import_origin(&target.join("demo")) + .await + .unwrap() + .unwrap() + .import_id, + second.import_id + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs index 8c0bdac376..16afe98d3e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs @@ -27,6 +27,7 @@ mod tests { source_id: "openbitfun".to_string(), source_label: "OpenBitFun".to_string(), installation_source: None, + import_origin: None, entry_file: None, dir_name: dir_name.to_string(), is_builtin: true, @@ -50,6 +51,7 @@ mod tests { source_id: "openbitfun".to_string(), source_label: "OpenBitFun".to_string(), installation_source: None, + import_origin: None, entry_file: None, dir_name: dir_name.to_string(), is_builtin: false, diff --git a/src/crates/assembly/core/src/external_mcp_import.rs b/src/crates/assembly/core/src/external_mcp_import.rs index f3d7339f03..1a3a0c2dbe 100644 --- a/src/crates/assembly/core/src/external_mcp_import.rs +++ b/src/crates/assembly/core/src/external_mcp_import.rs @@ -30,6 +30,7 @@ pub(crate) struct ExternalMcpImportCandidate { } struct ComputedPlan { + source_ids: BTreeMap, public: ExternalMcpImportPlanV1, target_fingerprint: String, target_native_ids: BTreeSet, @@ -211,6 +212,19 @@ fn selected_imports( .get(&selection.candidate_id) .ok_or(SelectionError::Stale)?; imports.push(MCPImportServer { + environment: prepared.environment.clone(), + headers: prepared.headers.clone(), + source_id: current.source_ids.get(&selection.candidate_id).cloned(), + working_directory: prepared + .working_directory + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + timeouts: openbitfun_services_integrations::mcp::MCPServerTimeouts { + startup_ms: prepared.timeouts.startup_ms, + catalog_ms: prepared.timeouts.catalog_ms, + execution_ms: prepared.timeouts.execution_ms, + }, + oauth_enabled: prepared.oauth_enabled, native_id, candidate_id: selection.candidate_id.clone(), behavior_version: prepared.behavior_version.clone(), @@ -235,6 +249,15 @@ fn build_import_plan( target: &MCPUserImportSnapshot, mut candidates: Vec, ) -> ComputedPlan { + let source_ids = candidates + .iter() + .map(|candidate| { + ( + candidate.definition.candidate_id(), + candidate.ecosystem_id.as_str().to_string(), + ) + }) + .collect(); candidates.sort_by(|left, right| left.definition.id.cmp(&right.definition.id)); let mut reserved = target.native_ids.clone(); let mut prepared = BTreeMap::new(); @@ -300,6 +323,7 @@ fn build_import_plan( }; public.plan_fingerprint = plan_fingerprint(target, &public, &prepared); ComputedPlan { + source_ids, public, target_fingerprint: target.fingerprint.clone(), target_native_ids: target.native_ids.clone(), @@ -419,6 +443,17 @@ fn plan_fingerprint( &serde_json::to_vec(&facts).expect("MCP import plan serialization cannot fail"), ); for (candidate_id, server) in prepared { + hash_part( + &mut hasher, + &serde_json::to_vec(&( + &server.working_directory, + &server.environment, + &server.headers, + server.timeouts, + server.oauth_enabled, + )) + .expect("MCP import options serialization cannot fail"), + ); hash_part(&mut hasher, candidate_id.as_bytes()); hash_part(&mut hasher, server.behavior_version.as_bytes()); match &server.transport { @@ -518,6 +553,11 @@ mod tests { }, ecosystem_id: EcosystemId::new("opencode").unwrap(), preparation: ExternalMcpImportPreparation::Prepared(PreparedExternalMcpImportServer { + environment: Default::default(), + headers: Default::default(), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, id, behavior_version: "sha256:behavior-v1".to_string(), transport: PreparedExternalMcpImportTransport::Local { @@ -561,6 +601,35 @@ mod tests { ); } + #[test] + fn import_options_are_versioned_and_forwarded_to_the_config_owner() { + let baseline = build_import_plan(&target(&[]), vec![candidate("node")]); + let mut changed = candidate("node"); + let cwd = std::env::current_dir().unwrap(); + if let ExternalMcpImportPreparation::Prepared(server) = &mut changed.preparation { + server.working_directory = Some(cwd.clone()); + server.timeouts.execution_ms = Some(60_000); + } + let plan = build_import_plan(&target(&[]), vec![changed]); + assert_ne!( + baseline.public.plan_fingerprint, + plan.public.plan_fingerprint + ); + let request = ExternalMcpImportApplyRequestV1 { + schema_version: EXTERNAL_MCP_IMPORT_SCHEMA_V1, + plan_fingerprint: plan.public.plan_fingerprint.clone(), + selections: vec![ + openbitfun_product_domains::external_sources::ExternalMcpImportSelectionV1 { + candidate_id: plan.public.items[0].candidate_id.clone(), + requested_native_id: None, + }, + ], + }; + let imports = selected_imports(&plan, &request).unwrap(); + assert_eq!(imports[0].working_directory.as_deref(), cwd.to_str()); + assert_eq!(imports[0].timeouts.execution_ms, Some(60_000)); + } + #[test] fn long_source_names_are_bounded_and_reported_as_automatic_renames() { let (native_id, renamed) = diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 02d4345953..a9c8460a17 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -65,6 +65,7 @@ use openbitfun_claude_code_adapter::{ ClaudeCodeCommandProvider, ClaudeCodeMcpProvider, ClaudeCodeSubagentProvider, }; use openbitfun_codex_adapter::{CodexMcpProvider, CodexSubagentProvider}; +use openbitfun_dsh_adapter::DshMcpProvider; use openbitfun_external_sources::{ DeferredDiscovery, ExternalMcpDiscoveryResult, ExternalSourceControlPlane, ExternalSourceCoordinator, ExternalSourceDiscoveryResult, ExternalSubagentDiscoveryResult, @@ -853,9 +854,45 @@ fn default_external_integration_registry() -> Vec mcp_provider: Some(Arc::new(CodexMcpProvider::default())), workspace_reference_provider: None, }, + ExternalEcosystemRegistration { + descriptor: ExternalIntegrationEcosystemDescriptor { + ecosystem_id: EcosystemId::new("deepseek-harness").expect("static ecosystem id"), + display_name: "DeepSeek Harness".to_string(), + adapter_revision: "1".to_string(), + capabilities: vec![external_capability_descriptor( + EXTERNAL_CAPABILITY_MCP, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + )], + }, + contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, + upstream_format_revision: "dsh-mcp-declarations-v1", + command_provider: None, + tool_provider: None, + subagent_provider: None, + mcp_provider: Some(Arc::new(DshMcpProvider::default())), + workspace_reference_provider: None, + }, ] } +/// Resolve legacy native import receipts using registered provider identity, even +/// when the external source is offline or no longer present. This does no discovery. +pub fn ecosystem_for_imported_mcp_candidate(candidate_id: &str) -> Option { + let qualified = + openbitfun_product_domains::external_sources::SourceQualifiedMcpServerId::from_stable_key( + candidate_id.strip_prefix("external_mcp:")?, + )?; + default_external_integration_registry() + .into_iter() + .find_map(|registration| { + let provider = registration.mcp_provider?; + let identity = provider.identity(); + (identity.provider_id == qualified.source.provider_id) + .then(|| identity.ecosystem_id.to_string()) + }) +} + fn default_external_integration_ecosystems() -> Vec { default_external_integration_registry() .into_iter() @@ -4743,6 +4780,16 @@ async fn service_for( service_for_profile(workspace_root, ExternalSourceServiceProfile::LocalExecution).await } +fn mcp_import_discovery_complete( + snapshot: &openbitfun_external_sources::ExternalMcpCoordinatorSnapshot, +) -> bool { + !snapshot.discovery_pending + && !snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code.starts_with("external_mcp.discovery_")) +} + pub(crate) async fn collect_external_mcp_import_candidates( workspace_root: Option<&Path>, ) -> Result, String> { @@ -4750,6 +4797,26 @@ pub(crate) async fn collect_external_mcp_import_candidates( service.refresh().await?; let coordinator = lock_mcp_coordinator(&service.control_plane); let snapshot = coordinator.snapshot(); + log::debug!( + "External MCP import discovery: providers={:?}, servers={}, pending={}, diagnostics={:?}", + snapshot + .sources + .iter() + .map(|source| source.record.key.provider_id.as_str()) + .collect::>(), + snapshot.servers.len(), + snapshot.discovery_pending, + snapshot + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect::>(), + ); + if !mcp_import_discovery_complete(&snapshot) { + return Err( + "External MCP discovery is incomplete; refresh before reviewing imports".to_string(), + ); + } let input_candidates = snapshot .servers .iter() @@ -7405,6 +7472,52 @@ mod opencode_local_source_order_tests; #[cfg(test)] mod tests { use super::*; + #[test] + fn imported_mcp_plan_does_not_treat_capacity_failures_as_an_empty_catalog() { + let mut snapshot = openbitfun_external_sources::ExternalMcpCoordinatorSnapshot { + generation: 1, + discovery_pending: false, + sources: vec![], + servers: vec![], + diagnostics: vec![], + }; + assert!(mcp_import_discovery_complete(&snapshot)); + snapshot.discovery_pending = true; + assert!(!mcp_import_discovery_complete(&snapshot)); + snapshot.discovery_pending = false; + snapshot.diagnostics.push(ExternalSourceDiagnostic::warning( + "external_mcp.discovery_overloaded", + "Discovery capacity is busy", + None, + )); + assert!(!mcp_import_discovery_complete(&snapshot)); + snapshot.diagnostics.clear(); + assert!(mcp_import_discovery_complete(&snapshot)); + } + #[test] + fn imported_mcp_legacy_receipt_keeps_registered_origin_without_discovery() { + use openbitfun_product_domains::external_sources::SourceQualifiedMcpServerId; + for (provider, ecosystem) in [ + ("codex.mcp", "codex"), + ("claude-code.mcp", "claude-code"), + ("opencode.mcp", "opencode"), + ] { + let id = SourceQualifiedMcpServerId::new( + SourceKey::new(provider, "removed-source").unwrap(), + "old-server", + ) + .unwrap(); + let candidate = format!("external_mcp:{}", id.stable_key()); + assert_eq!( + ecosystem_for_imported_mcp_candidate(&candidate).as_deref(), + Some(ecosystem) + ); + assert!( + ecosystem_for_imported_mcp_candidate(&format!("{candidate}invalid-tail")).is_none() + ); + } + assert!(ecosystem_for_imported_mcp_candidate("external_mcp:bad").is_none()); + } use crate::service::mcp::{ConfigLocation, MCPServerConfig, MCPServerType}; use openbitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, @@ -9367,9 +9480,13 @@ mod tests { #[test] fn default_registry_exposes_only_each_ecosystems_supported_asset_kinds() { let registrations = default_external_integration_registry(); - assert_eq!(registrations.len(), 3); + assert_eq!(registrations.len(), 4); let expected = BTreeMap::from([ + ( + "deepseek-harness", + BTreeSet::from([EXTERNAL_CAPABILITY_MCP]), + ), ( "opencode", BTreeSet::from([ diff --git a/src/crates/assembly/core/src/service/mcp/server/config.rs b/src/crates/assembly/core/src/service/mcp/server/config.rs index 8b68e7e1f4..e9ec70c558 100644 --- a/src/crates/assembly/core/src/service/mcp/server/config.rs +++ b/src/crates/assembly/core/src/service/mcp/server/config.rs @@ -4,7 +4,7 @@ use crate::util::errors::OpenBitFunError; use openbitfun_services_integrations::mcp::server::MCPServerConfigValidationError; pub use openbitfun_services_integrations::mcp::server::{ - MCPServerConfig, MCPServerOAuthConfig, MCPServerTimeouts, MCPServerTransport, + MCPImportOrigin, MCPServerConfig, MCPServerOAuthConfig, MCPServerTimeouts, MCPServerTransport, MCPServerXaaConfig, }; diff --git a/src/crates/assembly/core/src/service/mcp/server/mod.rs b/src/crates/assembly/core/src/service/mcp/server/mod.rs index 4cad5f3892..74aa7f7dd1 100644 --- a/src/crates/assembly/core/src/service/mcp/server/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/server/mod.rs @@ -9,7 +9,7 @@ mod process; mod registry; pub use config::{ - MCPServerConfig, MCPServerOAuthConfig, MCPServerTimeouts, MCPServerTransport, + MCPImportOrigin, MCPServerConfig, MCPServerOAuthConfig, MCPServerTimeouts, MCPServerTransport, MCPServerXaaConfig, }; pub use connection::{MCPConnection, MCPConnectionPool}; diff --git a/src/crates/assembly/external-sources/src/refresh.rs b/src/crates/assembly/external-sources/src/refresh.rs index a96ceec888..13a6a0b5cd 100644 --- a/src/crates/assembly/external-sources/src/refresh.rs +++ b/src/crates/assembly/external-sources/src/refresh.rs @@ -496,7 +496,10 @@ fn spawn_discovery_task( budget: Arc, ) -> SharedDiscoveryTask { async move { - let permit = match budget.try_acquire_owned() { + // A refresh can contain more providers than worker slots. Keep those + // providers queued under the existing discovery/deferred deadlines; + // capacity contention must not turn a valid source into an empty scan. + let permit = match budget.acquire_owned().await { Ok(permit) => permit, Err(_) => { return R::failed( @@ -504,7 +507,7 @@ fn spawn_discovery_task( discovery_error::( "discovery_overloaded", format!( - "{} provider discovery could not start because the process-wide discovery budget is full", + "{} provider discovery could not start because the process-wide discovery budget is closed", R::PROVIDER_LABEL ), ), @@ -655,6 +658,37 @@ mod tests { } } + #[tokio::test] + async fn providers_exceeding_worker_capacity_queue_and_all_complete() { + let lane = DiscoveryLane::::with_limits(1, Duration::from_secs(1)); + let held = Arc::clone(&lane.budget).acquire_owned().await.unwrap(); + let batch = lane + .discover( + ["first", "second", "third"] + .into_iter() + .map(|id| FakeRequest::blocked(id, Arc::new(AtomicBool::new(true)))) + .collect(), + Duration::from_millis(5), + ) + .await; + assert_eq!(batch.deferred.len(), 3); + assert!(batch + .immediate + .iter() + .all(|result| result.kind == FakeResultKind::TimedOut)); + drop(held); + let results = futures::future::join_all(batch.deferred.into_iter().map(|deferred| async { + let completed = lane.complete_deferred(deferred).await.unwrap().0; + lane.finalize_deferred(completed).await.unwrap() + })) + .await; + assert_eq!(results.len(), 3); + assert!(results + .iter() + .all(|result| result.kind == FakeResultKind::Success)); + assert_eq!(lane.budget.available_permits(), 1); + } + #[tokio::test] async fn timed_out_request_is_reused_and_completes_once() { let lane = DiscoveryLane::::new(); @@ -882,17 +916,24 @@ mod tests { Duration::from_millis(5), ) .await; - assert_eq!(second.immediate[0].kind, FakeResultKind::Overloaded); - assert!(second.deferred.is_empty()); + assert_eq!(second.immediate[0].kind, FakeResultKind::TimedOut); + assert_eq!(second.deferred.len(), 1); blocked_release.store(true, Ordering::Release); let resumed = lane .resume_abandoned(observer.expect("one exit observer is retained")) .await; let resumed = resumed.expect("the pending generation starts when the old worker exits"); - let resumed = lane - .complete_deferred(resumed) + let (resumed, replacement) = tokio::join!( + lane.complete_deferred(resumed), + lane.complete_deferred(second.deferred.into_iter().next().unwrap()), + ); + let replacement = lane + .finalize_deferred(replacement.unwrap().0) .await + .unwrap(); + assert_eq!(replacement.kind, FakeResultKind::Success); + let resumed = resumed .expect("the newest pending request runs after the abandoned worker exits") .0; let resumed = lane diff --git a/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/mcp_coordinator.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/mcp_coordinator.rs index 5cf797aff8..8ae7514fd5 100644 --- a/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/mcp_coordinator.rs +++ b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/mcp_coordinator.rs @@ -150,6 +150,11 @@ impl ExternalMcpSourceProvider for FakeProvider { expected_behavior_version: &str, ) -> Result { Ok(PreparedExternalMcpImportServer { + environment: Default::default(), + headers: Default::default(), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, id: server_id.clone(), behavior_version: expected_behavior_version.to_string(), transport: PreparedExternalMcpImportTransport::Remote { diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 24b23a7fee..a38e8380e3 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -495,6 +495,16 @@ pub struct SourceQualifiedMcpServerId { } impl SourceQualifiedMcpServerId { + pub fn from_stable_key(value: &str) -> Option { + let (provider, remainder) = take_length_prefixed(value)?; + let (source, remainder) = take_length_prefixed(remainder)?; + let (local, remainder) = take_length_prefixed(remainder)?; + if !remainder.is_empty() { + return None; + } + Self::new(SourceKey::new(provider, source).ok()?, local).ok() + } + pub fn new( source: SourceKey, local_id: impl Into, @@ -879,9 +889,14 @@ impl fmt::Debug for PreparedExternalMcpImportTransport { #[derive(Clone, PartialEq, Eq)] pub struct PreparedExternalMcpImportServer { + pub environment: std::collections::BTreeMap, + pub headers: std::collections::BTreeMap, pub id: SourceQualifiedMcpServerId, pub behavior_version: String, pub transport: PreparedExternalMcpImportTransport, + pub working_directory: Option, + pub timeouts: ExternalMcpTimeouts, + pub oauth_enabled: Option, } impl fmt::Debug for PreparedExternalMcpImportServer { @@ -891,12 +906,65 @@ impl fmt::Debug for PreparedExternalMcpImportServer { .field("id", &self.id) .field("behavior_version", &self.behavior_version) .field("transport", &self.transport) + .field( + "working_directory", + &self.working_directory.as_ref().map(|_| "[REDACTED]"), + ) + .field("timeouts", &self.timeouts) + .field("oauth_enabled", &self.oauth_enabled) .finish() } } impl PreparedExternalMcpImportServer { pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + for (values, headers) in [(&self.environment, false), (&self.headers, true)] { + if values.len() > 256 + || values.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 256 + || key.contains(['=', '\0', '\r', '\n']) + || value.len() > 65536 + || value.contains('\0') + || (headers + && (value.contains(['\r', '\n']) + || !key.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || b"!#$%&'*+-.^_`|~".contains(&byte) + }))) + }) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared MCP import environment or headers", + )); + } + } + + self.timeouts.validate()?; + if let Some(directory) = &self.working_directory { + if !directory.is_absolute() || directory.to_str().is_none() { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared MCP import working directory", + )); + } + validate_text( + directory.to_str().expect("validated UTF-8"), + "prepared MCP import working directory", + )?; + } + if matches!( + &self.transport, + PreparedExternalMcpImportTransport::Local { .. } + ) && self.oauth_enabled.is_some() + || matches!( + &self.transport, + PreparedExternalMcpImportTransport::Remote { .. } + ) && self.working_directory.is_some() + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared MCP import transport options", + )); + } validate_text( &self.behavior_version, "prepared MCP import behavior version", diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs index ba25393d3b..2d3cd71d09 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs @@ -66,6 +66,11 @@ fn native_prompt_command_descriptors_reject_external_candidate_namespaces() { fn external_mcp_import_contract_keeps_private_values_out_of_debug_and_requests() { let source = SourceKey::new("opencode.mcp", "user-config").unwrap(); let prepared = PreparedExternalMcpImportServer { + environment: Default::default(), + headers: Default::default(), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, id: SourceQualifiedMcpServerId::new(source, "docs").unwrap(), behavior_version: "sha256:behavior-v1".to_string(), transport: PreparedExternalMcpImportTransport::Local { @@ -95,6 +100,11 @@ fn external_mcp_import_contract_keeps_private_values_out_of_debug_and_requests() #[test] fn external_mcp_import_contract_rejects_urls_that_cannot_be_copied_losslessly() { let prepared = |url: &str| PreparedExternalMcpImportServer { + environment: Default::default(), + headers: Default::default(), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, id: SourceQualifiedMcpServerId::new( SourceKey::new("codex.mcp", "user-config").unwrap(), "docs", diff --git a/src/crates/execution/agent-runtime/src/skills/mod.rs b/src/crates/execution/agent-runtime/src/skills/mod.rs index 055a45dae7..24784481a7 100644 --- a/src/crates/execution/agent-runtime/src/skills/mod.rs +++ b/src/crates/execution/agent-runtime/src/skills/mod.rs @@ -43,6 +43,7 @@ pub use selection::{ ExplicitSkillInvocationResolution, SkillCandidate, }; pub use types::{ - render_loaded_skill_for_assistant, ModeSkillInfo, ModeSkillStateReason, SkillData, SkillInfo, - SkillLocation, SkillParseError, SkillScanDiagnostic, SkillScanReport, + render_loaded_skill_for_assistant, ModeSkillInfo, ModeSkillStateReason, SkillData, + SkillImportOrigin, SkillInfo, SkillLocation, SkillParseError, SkillScanDiagnostic, + SkillScanReport, }; diff --git a/src/crates/execution/agent-runtime/src/skills/selection.rs b/src/crates/execution/agent-runtime/src/skills/selection.rs index 31c1182c91..836d2ff667 100644 --- a/src/crates/execution/agent-runtime/src/skills/selection.rs +++ b/src/crates/execution/agent-runtime/src/skills/selection.rs @@ -41,6 +41,7 @@ impl SkillCandidate { source_id: source_id.to_string(), source_label: source_label.to_string(), installation_source: None, + import_origin: None, entry_file: data.entry_file, dir_name: data.dir_name, is_builtin, @@ -335,6 +336,7 @@ mod tests { source_id: String::new(), source_label: String::new(), installation_source: None, + import_origin: None, entry_file: None, dir_name: name.to_string(), is_builtin: false, diff --git a/src/crates/execution/agent-runtime/src/skills/types.rs b/src/crates/execution/agent-runtime/src/skills/types.rs index 6e1f62c299..63fd945840 100644 --- a/src/crates/execution/agent-runtime/src/skills/types.rs +++ b/src/crates/execution/agent-runtime/src/skills/types.rs @@ -74,6 +74,19 @@ impl SkillLocation { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillImportOrigin { + pub schema_version: u32, + pub import_id: String, + pub source_key: String, + pub source_path: String, + pub source_id: String, + pub source_label: String, + pub source_slot: String, + pub fingerprint: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillInfo { @@ -95,6 +108,9 @@ pub struct SkillInfo { /// Repository recorded by the installer, distinct from the discovery ecosystem. #[serde(default, skip_serializing_if = "Option::is_none")] pub installation_source: Option, + /// Provenance of a native copy; storage ownership stays with source_id/source_slot. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub import_origin: Option, pub dir_name: String, #[serde(default)] pub is_builtin: bool, @@ -113,6 +129,44 @@ pub struct SkillInfo { } impl SkillInfo { + /// Native storage ownership, independent of the provenance of an imported copy. + /// Older payloads may only carry the source slot. + pub fn is_native(&self) -> bool { + if self.is_builtin { + return true; + } + let source = if self.source_id.trim().is_empty() { + self.source_slot.trim() + } else { + self.source_id.trim() + }; + matches!( + source, + "" | "openbitfun" | "openbitfun-system" | "openbitfun-user" + ) + } + + pub fn parser_source_slot(&self) -> &str { + self.import_origin + .as_ref() + .map_or(&self.source_slot, |origin| &origin.source_slot) + } + + pub fn parser_path(&self) -> String { + match self + .entry_file + .as_deref() + .filter(|entry| *entry != "SKILL.md") + { + Some(entry) => format!( + "{}/{}", + self.path.trim_end_matches(['/', '\\']), + entry.strip_suffix(".md").unwrap_or(entry) + ), + None => self.path.clone(), + } + } + pub fn to_xml_desc(&self) -> String { format!( r#"{}"#, diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs index 9275872a1f..ea5aa78f33 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs @@ -25,6 +25,7 @@ fn builtin_skill(dir_name: &str) -> SkillInfo { source_id: "openbitfun".to_string(), source_label: "OpenBitFun".to_string(), installation_source: None, + import_origin: None, entry_file: None, dir_name: dir_name.to_string(), is_builtin: true, @@ -48,6 +49,7 @@ fn custom_user_skill(dir_name: &str) -> SkillInfo { source_id: "openbitfun".to_string(), source_label: "OpenBitFun".to_string(), installation_source: None, + import_origin: None, entry_file: None, dir_name: dir_name.to_string(), is_builtin: false, @@ -74,6 +76,62 @@ fn skill_installation_source_is_optional_for_legacy_payloads_and_round_trips() { assert_eq!(current.installation_source.as_deref(), Some("first/skills")); } +#[test] +fn import_origin_round_trips_without_changing_native_ownership_or_legacy_payloads() { + let legacy = serde_json::to_value(custom_user_skill("demo")).unwrap(); + assert!(legacy.get("importOrigin").is_none()); + let mut skill: SkillInfo = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(serde_json::to_value(&skill).unwrap(), legacy); + skill.import_origin = Some(openbitfun_agent_runtime::skills::SkillImportOrigin { + schema_version: 1, + import_id: "import-1".into(), + source_key: "user::home.claude::demo".into(), + source_path: "/external/demo".into(), + source_id: "claude-code".into(), + source_label: "Claude Code".into(), + source_slot: "home.claude".into(), + fingerprint: "fixture-hash".into(), + }); + let decoded: SkillInfo = serde_json::from_value(serde_json::to_value(&skill).unwrap()).unwrap(); + assert_eq!(decoded.import_origin, skill.import_origin); + assert_eq!(decoded.source_id, "openbitfun"); + assert_eq!(decoded.parser_source_slot(), "home.claude"); + assert!(decoded.is_native()); +} + +#[test] +fn native_ownership_rejects_discovery_sources_and_honors_legacy_slots() { + for source in [ + "claude-code", + "codex", + "cursor", + "opencode", + "agent-skills", + "deepseek-harness", + "pi", + ] { + let mut skill = custom_user_skill("external"); + skill.source_id = source.into(); + assert!(!skill.is_native(), "{source}"); + } + for (slot, expected) in [ + ("openbitfun", true), + ("openbitfun-system", true), + ("home.claude", false), + ("codex", false), + ] { + let mut legacy = serde_json::to_value(custom_user_skill("legacy")).unwrap(); + legacy.as_object_mut().unwrap().remove("sourceId"); + legacy.as_object_mut().unwrap().remove("sourceLabel"); + legacy["sourceSlot"] = slot.into(); + let decoded: SkillInfo = serde_json::from_value(legacy).unwrap(); + assert_eq!(decoded.is_native(), expected, "{slot}"); + let round_trip: SkillInfo = + serde_json::from_value(serde_json::to_value(decoded).unwrap()).unwrap(); + assert_eq!(round_trip.is_native(), expected); + } +} + #[test] fn skill_source_dialect_is_derived_from_the_stable_source_slot() { let markdown = "---\ndescription: Directory fallback.\n---\n\nBody.\n"; @@ -364,6 +422,7 @@ fn project_skill(dir_name: &str) -> SkillInfo { source_id: "openbitfun".to_string(), source_label: "OpenBitFun".to_string(), installation_source: None, + import_origin: None, entry_file: None, dir_name: dir_name.to_string(), is_builtin: false, diff --git a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs index 613ab9fabd..f4c0f4bcc4 100644 --- a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs +++ b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs @@ -44,6 +44,9 @@ fn parse_legacy_type(value: &str) -> Option<(Option, Option serde_json::Value { let mut cursor_config = serde_json::Map::new(); + if let Some(origin) = config.settings.get("_openbitfunImport") { + cursor_config.insert("_openbitfunImport".into(), origin.clone()); + } let type_str = match (config.server_type, config.resolved_transport()) { (MCPServerType::Local, _) => "stdio", @@ -66,6 +69,12 @@ pub fn config_to_cursor_format(config: &MCPServerConfig) -> serde_json::Value { if let Some(command) = &config.command { cursor_config.insert("command".to_string(), serde_json::json!(command)); } + if let Some(directory) = &config.working_directory { + cursor_config.insert("workingDirectory".into(), serde_json::json!(directory)); + } + if !config.timeouts.is_empty() { + cursor_config.insert("timeouts".into(), serde_json::json!(config.timeouts)); + } if let Some(inherit) = config.inherit_parent_environment { cursor_config.insert( @@ -236,7 +245,10 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { command, args, env, - working_directory: None, + working_directory: obj + .get("workingDirectory") + .and_then(|v| v.as_str()) + .map(str::to_owned), inherit_parent_environment, headers, url, @@ -244,7 +256,15 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { enabled, location: ConfigLocation::User, capabilities: Vec::new(), - settings: Default::default(), + settings: obj + .get("_openbitfunImport") + .filter(|value| value.is_object()) + .map(|value| { + [("_openbitfunImport".to_string(), value.clone())] + .into_iter() + .collect() + }) + .unwrap_or_default(), oauth: obj .get("oauth") .cloned() @@ -259,7 +279,23 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { .get("xaa") .cloned() .and_then(|value| serde_json::from_value(value).ok()), - timeouts: Default::default(), + timeouts: match obj.get("timeouts") { + None => Default::default(), + Some(value) => { + match serde_json::from_value::( + value.clone(), + ) { + Ok(timeouts) if timeouts.validate().is_ok() => timeouts, + _ => { + warn!( + "Invalid MCP timeout configuration for server '{}'", + server_id + ); + continue; + } + } + } + }, }; servers.push(server_config); diff --git a/src/crates/services/services-integrations/src/mcp/config/import.rs b/src/crates/services/services-integrations/src/mcp/config/import.rs index 3d5d6f6094..2bcadf972d 100644 --- a/src/crates/services/services-integrations/src/mcp/config/import.rs +++ b/src/crates/services/services-integrations/src/mcp/config/import.rs @@ -38,11 +38,17 @@ impl fmt::Debug for MCPImportTransport { #[derive(Clone, PartialEq, Eq)] pub struct MCPImportServer { + pub environment: std::collections::BTreeMap, + pub headers: std::collections::BTreeMap, + pub source_id: Option, pub native_id: String, pub candidate_id: String, pub behavior_version: String, pub display_name: String, pub transport: MCPImportTransport, + pub working_directory: Option, + pub timeouts: crate::mcp::MCPServerTimeouts, + pub oauth_enabled: Option, } impl fmt::Debug for MCPImportServer { @@ -54,6 +60,12 @@ impl fmt::Debug for MCPImportServer { .field("behavior_version", &self.behavior_version) .field("display_name", &self.display_name) .field("transport", &self.transport) + .field( + "working_directory", + &self.working_directory.as_ref().map(|_| "[REDACTED]"), + ) + .field("timeouts", &self.timeouts) + .field("oauth_enabled", &self.oauth_enabled) .finish() } } @@ -240,6 +252,42 @@ impl MCPConfigService { impl MCPImportServer { fn validate(&self) -> Result<(), MCPImportError> { + for (values, headers) in [(&self.environment, false), (&self.headers, true)] { + if values.len() > 256 + || values.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 256 + || key.contains(['=', '\0', '\r', '\n']) + || value.len() > 65536 + || value.contains('\0') + || (headers + && (value.contains(['\r', '\n']) + || !key.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || b"!#$%&'*+-.^_`|~".contains(&byte) + }))) + }) + { + return Err(MCPImportError::InvalidRequest("environment or headers")); + } + } + + self.timeouts + .validate() + .map_err(|_| MCPImportError::InvalidRequest("timeouts"))?; + if let Some(directory) = &self.working_directory { + validate_text(directory, "working directory")?; + if !std::path::Path::new(directory).is_absolute() { + return Err(MCPImportError::InvalidRequest("working directory")); + } + } + if matches!(&self.transport, MCPImportTransport::Local { .. }) + && self.oauth_enabled.is_some() + || matches!(&self.transport, MCPImportTransport::Remote { .. }) + && self.working_directory.is_some() + { + return Err(MCPImportError::InvalidRequest("transport options")); + } validate_id(&self.native_id, "native id")?; validate_id(&self.candidate_id, "candidate id")?; validate_id(&self.behavior_version, "behavior version")?; @@ -314,6 +362,21 @@ fn cursor_servers(current: &Option) -> Result, MCPImpo fn imported_server_value(import: MCPImportServer) -> Value { let mut server = Map::new(); + if !import.environment.is_empty() { + server.insert("env".into(), serde_json::json!(import.environment)); + } + if !import.headers.is_empty() { + server.insert("headers".into(), serde_json::json!(import.headers)); + } + if let Some(directory) = import.working_directory { + server.insert("workingDirectory".into(), Value::String(directory)); + } + if !import.timeouts.is_empty() { + server.insert("timeouts".into(), serde_json::json!(import.timeouts)); + } + if let Some(enabled) = import.oauth_enabled { + server.insert("oauthEnabled".into(), Value::Bool(enabled)); + } match import.transport { MCPImportTransport::Local { command, args } => { server.insert("type".to_string(), Value::String("stdio".to_string())); @@ -341,6 +404,7 @@ fn imported_server_value(import: MCPImportServer) -> Value { serde_json::json!({ "sourceCandidateId": import.candidate_id, "behaviorVersion": import.behavior_version, + "sourceId": import.source_id, }), ); Value::Object(server) diff --git a/src/crates/services/services-integrations/src/mcp/config/json_config.rs b/src/crates/services/services-integrations/src/mcp/config/json_config.rs index 7966097bd8..20ea6ab6e7 100644 --- a/src/crates/services/services-integrations/src/mcp/config/json_config.rs +++ b/src/crates/services/services-integrations/src/mcp/config/json_config.rs @@ -236,11 +236,14 @@ pub fn validate_mcp_json_config( ("env", "object"), ("headers", "object"), ("xaa", "object"), + ("timeouts", "object"), + ("workingDirectory", "string"), ] { if let Some(value) = obj.get(key) { let matches_expected = match expected { "array" => value.is_array(), "object" => value.is_object(), + "string" => value.is_string(), _ => false, }; if !matches_expected { @@ -278,6 +281,15 @@ pub fn validate_mcp_json_config( ))); } } + if let Some(value) = obj.get("timeouts") { + let valid = serde_json::from_value::(value.clone()) + .is_ok_and(|timeouts| timeouts.validate().is_ok()); + if !valid { + return Err(MCPJsonConfigValidationError::new(format!( + "Server '{}' timeouts must contain positive exactly representable millisecond integers", server_id + ))); + } + } } Ok(()) diff --git a/src/crates/services/services-integrations/src/mcp/server/mod.rs b/src/crates/services/services-integrations/src/mcp/server/mod.rs index b46a45a465..3a5fd9f0f3 100644 --- a/src/crates/services/services-integrations/src/mcp/server/mod.rs +++ b/src/crates/services/services-integrations/src/mcp/server/mod.rs @@ -137,6 +137,16 @@ impl MCPServerTimeouts { } } +/// MCP server configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MCPImportOrigin { + pub source_candidate_id: String, + pub behavior_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_id: Option, +} + /// MCP server configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/crates/services/services-integrations/tests/mcp_contracts.rs b/src/crates/services/services-integrations/tests/mcp_contracts.rs index d103c67e66..920e5110db 100644 --- a/src/crates/services/services-integrations/tests/mcp_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_contracts.rs @@ -1083,6 +1083,122 @@ async fn mcp_config_service_orchestration_preserves_load_save_delete_contract() .is_none()); } +#[tokio::test] +async fn external_mcp_import_options_survive_load_save_and_legacy_round_trip() { + let store = Arc::new(InMemoryMCPConfigStore::default()); + let service = MCPConfigService::new(store.clone()); + let directory = tempfile::tempdir().unwrap(); + let cwd = directory.path().to_string_lossy().into_owned(); + let timeouts = openbitfun_services_integrations::mcp::MCPServerTimeouts { + startup_ms: Some(1250), + catalog_ms: Some(2500), + execution_ms: Some(60_000), + }; + let snapshot = service.user_import_snapshot().await.unwrap(); + service + .apply_user_import( + &snapshot.fingerprint, + vec![ + MCPImportServer { + environment: [("TOKEN".into(), "literal-secret".into())].into(), + headers: Default::default(), + source_id: Some("codex".into()), + native_id: "local".into(), + candidate_id: "deepseek-harness:mcp:local".into(), + behavior_version: "v1".into(), + display_name: "local".into(), + transport: MCPImportTransport::Local { + command: "node".into(), + args: vec!["./server.js".into()], + }, + working_directory: Some(cwd.clone()), + timeouts, + oauth_enabled: None, + }, + MCPImportServer { + environment: Default::default(), + headers: [("Authorization".into(), "Bearer header-secret".into())].into(), + source_id: Some("codex".into()), + native_id: "remote".into(), + candidate_id: "deepseek-harness:mcp:remote".into(), + behavior_version: "v1".into(), + display_name: "remote".into(), + transport: MCPImportTransport::Remote { + url: "https://example.test/mcp".into(), + }, + working_directory: None, + timeouts, + oauth_enabled: Some(false), + }, + ], + ) + .await + .unwrap(); + for id in ["local", "remote"] { + let config = service.get_server_config(id).await.unwrap().unwrap(); + assert!(!config.enabled); + assert!(!config.auto_start); + assert_eq!(config.timeouts, timeouts); + if id == "local" { + assert_eq!(config.working_directory.as_deref(), Some(cwd.as_str())); + } else { + assert_eq!(config.oauth_enabled, Some(false)); + assert!(!config.remote_oauth_enabled()); + } + service.save_server_config(&config).await.unwrap(); + let loaded = service.get_server_config(id).await.unwrap().unwrap(); + assert_eq!(loaded.timeouts, timeouts); + assert_eq!(loaded.env, config.env); + assert_eq!(loaded.headers, config.headers); + assert_eq!(loaded.settings["_openbitfunImport"]["sourceId"], "codex"); + if id == "local" { + assert_eq!( + loaded.env.get("TOKEN").map(String::as_str), + Some("literal-secret") + ); + } else { + assert_eq!( + loaded.headers.get("Authorization").map(String::as_str), + Some("Bearer header-secret") + ); + } + assert_eq!(loaded.working_directory, config.working_directory); + assert_eq!(loaded.oauth_enabled, config.oauth_enabled); + } + let legacy = serde_json::json!({"mcpServers":{"legacy":{"command":"old-server"}}}); + let parsed = parse_cursor_format(&legacy); + assert_eq!(parsed.len(), 1); + assert!(parsed[0].timeouts.is_empty()); + assert!(parsed[0].working_directory.is_none()); + assert!(parsed[0].oauth_enabled.is_none()); + let round_trip = + serde_json::json!({"mcpServers":{"legacy": config_to_cursor_format(&parsed[0])}}); + let reloaded = parse_cursor_format(&round_trip); + assert_eq!(reloaded[0].command, parsed[0].command); + assert!(reloaded[0].timeouts.is_empty()); +} + +#[test] +fn mcp_json_import_options_reject_malformed_values_before_saving() { + for (key, value) in [ + ("timeouts", serde_json::json!({"executionMs": 0})), + ( + "timeouts", + serde_json::json!({"startupMs": 9_007_199_254_740_992u64}), + ), + ("timeouts", serde_json::json!({"executionMs": "60000"})), + ("workingDirectory", serde_json::json!(false)), + ("oauthEnabled", serde_json::json!("false")), + ] { + let mut config = serde_json::json!({"mcpServers":{"docs":{"command":"docs-server"}}}); + config["mcpServers"]["docs"][key] = value; + assert!( + validate_mcp_json_config(&config).is_err(), + "accepted malformed {key}" + ); + } +} + #[tokio::test] async fn external_mcp_import_is_atomic_disabled_and_idempotence_visible() { let store = Arc::new(InMemoryMCPConfigStore::default()); @@ -1092,6 +1208,12 @@ async fn external_mcp_import_is_atomic_disabled_and_idempotence_visible() { .apply_user_import( &snapshot.fingerprint, vec![MCPImportServer { + environment: Default::default(), + headers: Default::default(), + source_id: Some("codex".into()), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, native_id: "docs".to_string(), candidate_id: "opencode:mcp:docs".to_string(), behavior_version: "sha256:behavior-v1".to_string(), @@ -1121,6 +1243,12 @@ async fn external_mcp_import_is_atomic_disabled_and_idempotence_visible() { .apply_user_import( &snapshot.fingerprint, vec![MCPImportServer { + environment: Default::default(), + headers: Default::default(), + source_id: Some("codex".into()), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, native_id: "other".to_string(), candidate_id: "opencode:mcp:other".to_string(), behavior_version: "sha256:behavior-v1".to_string(), @@ -1146,6 +1274,12 @@ async fn stale_full_json_save_cannot_overwrite_a_concurrent_import() { .apply_user_import( &import_snapshot.fingerprint, vec![MCPImportServer { + environment: Default::default(), + headers: Default::default(), + source_id: Some("codex".into()), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, native_id: "docs".to_string(), candidate_id: "opencode:mcp:docs".to_string(), behavior_version: "sha256:behavior-v1".to_string(), @@ -1171,6 +1305,12 @@ async fn stale_full_json_save_cannot_overwrite_a_concurrent_import() { #[test] fn import_debug_output_redacts_private_transport_values() { let import = MCPImportServer { + environment: Default::default(), + headers: Default::default(), + source_id: Some("codex".into()), + working_directory: None, + timeouts: Default::default(), + oauth_enabled: None, native_id: "docs".to_string(), candidate_id: "opencode:mcp:docs".to_string(), behavior_version: "sha256:behavior-v1".to_string(), diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemBatchLayout.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemBatchLayout.tsx new file mode 100644 index 0000000000..19dad73f56 --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemBatchLayout.tsx @@ -0,0 +1,53 @@ +import { Fragment, useRef, type ReactNode } from 'react'; +import { Button, DialogBody, Icon } from '@openbitfun/ui'; +import { Package, Server, Webhook } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; + +type ImportKind = 'skill' | 'mcp' | 'hook'; +const GROUPS = [ + { kind: 'skill', glyph: Package }, + { kind: 'mcp', glyph: Server }, + { kind: 'hook', glyph: Webhook }, +] as const; + +/** Keep review/progress visible while only the typed item list scrolls. */ +export function EcosystemBatchLayout({ entries, getKind, renderEntry, summary, processed, busy, children }: { + entries: T[]; + getKind: (entry: T) => ImportKind; + renderEntry: (entry: T) => ReactNode; + summary: ReactNode; + processed?: number; + busy: boolean; + children?: ReactNode; +}) { + const { t, formatNumber } = useI18n('scenes/ecosystem-compatibility'); + const groupElements = useRef>>({}); + const groups = GROUPS.map((group) => ({ ...group, entries: entries.filter((entry) => getKind(entry) === group.kind) })).filter((group) => group.entries.length); + const progressLabel = t('content.batchProgress', { completed: formatNumber(processed ?? 0), total: formatNumber(entries.length) }); + return <> +
+

{summary}

+
+ {groups.map(({ kind, glyph, entries: group }) => )} +
+ {processed !== undefined ?
+
{progressLabel}{busy ? {t(processed === entries.length ? 'content.batchRefreshing' : 'content.batchProcessing')} : null}
+ +
: null} +
+ +
+ {groups.map(({ kind, glyph, entries: group }) => { + return
{ groupElements.current[kind] = element; }} className="ecosystem-compatibility__batch-group" aria-label={t(`capabilities.${kind}`)}> +

{t(`capabilities.${kind}`)}{formatNumber(group.length)}

+ {group.map((entry) => {renderEntry(entry)})} +
; + })} + {children} +
+
+ ; +} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx new file mode 100644 index 0000000000..2c79824b1c --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx @@ -0,0 +1,194 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ExternalSourceCatalogSnapshot, ExternalMcpImportPlanV1 } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { catalogDiscoveryState } from './ecosystemCompatibilityModel'; + +const mocks = vi.hoisted(() => ({ + getSnapshot: vi.fn(), + planMcpImport: vi.fn<() => Promise>(), + applyMcpImport: vi.fn(), + ownerSurface: null as string | null, + selectedProductId: 'codex', + setOwnerSurface: vi.fn(), + workspacePath: '/workspace', + peerDeviceId: '', + skills: [] as Array>, + t: (key: string, values?: Record) => key === 'header.checksSummary' ? `assets:${values?.assetCount}` : key, +})); +vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ externalSourcesAPI: mocks })); +vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ ACPClientAPI: { getClients: async () => [] } })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspacePath: mocks.workspacePath, workspace: { workspaceKind: 'normal', sshHost: 'localhost' } }) })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: mocks.t, formatNumber: String }) })); +vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => mocks.peerDeviceId ? ({ peerMode: { active: true, deviceId: mocks.peerDeviceId } }) : null })); +vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }) })); +vi.mock('./ecosystemCompatibilityStore', () => ({ + useEcosystemCompatibilityStore: (select: (value: unknown) => unknown) => select({ + selectedProductId: mocks.selectedProductId, ownerSurface: mocks.ownerSurface, setOwnerSurface: mocks.setOwnerSurface, + }), +})); +vi.mock('@openbitfun/ui', async () => { + const { createElement } = await import('react'); + const Wrapper = ({ children }: { children?: React.ReactNode }) => createElement('div', null, children); + return { + ...Object.fromEntries(['LoadingState', 'NavigationPanel', 'NavigationPanelBody', 'NavigationPanelContent', + 'NavigationPanelFooter', 'NavigationPanelHeader', 'NavigationPanelItem', 'NavigationPanelSection', + 'OverflowText', 'ScrollArea', 'SearchField', 'Select', 'StatusPill', 'Switch', 'Textarea', 'DialogBody', 'DialogFooter', 'DialogHeader', 'DialogHeading', 'DialogTitle'].map((name) => [name, Wrapper])), + IconButton: ({ icon, ...props }: React.ButtonHTMLAttributes & { icon: React.ReactNode }) => createElement('button', props, icon), + Checkbox: ({ size: _size, ...props }: React.InputHTMLAttributes) => createElement('input', { type: 'checkbox', ...props }), + Icon: () => null, DialogClose: () => null, + Dialog: ({ open, children }: { open: boolean; children?: React.ReactNode }) => open ? createElement('div', null, children) : null, + Button: ({ children, onClick, disabled, 'aria-label': label }: React.ButtonHTMLAttributes) => createElement('button', { onClick, disabled, 'aria-label': label }, children), + }; +}); + +vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: { getSkillScanReport: async () => ({ skills: mocks.skills, diagnostics: [] }) } })); +vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ externalHooksAPI: { + getCatalog: async () => ({ sources: [], entries: [], providers: [], failedProviderIds: [], discoveryPending: false }), + getImportSnapshot: async () => ({ catalog: { sources: [], entries: [], providers: [], failedProviderIds: [], discoveryPending: false }, imports: [] }), +} })); + +import EcosystemCompatibilityScene from './EcosystemCompatibilityScene'; + +function snapshot(enabled: boolean, pending = false, discovered = false): ExternalSourceCatalogSnapshot { + const source = { providerId: 'codex.mcp', sourceId: 'user' }; + return { + generation: discovered ? 2 : 1, discoveryPending: pending, + hostCapabilities: { canMutatePolicy: true, canManageSources: true, canApproveRuntime: true }, + integrationPolicy: { + status: 'compatible', registeredEcosystems: [], + effective: { enabled, ecosystems: { codex: { capabilities: { mcp: 'ask_before_use', subagent: 'ask_before_use' } } } }, + }, + commands: [], + sources: discovered ? [{ stableKey: 'codex:user', record: { + key: source, ecosystemId: 'codex', displayName: 'Codex', location: '/config.toml', + health: 'available', diagnostics: [], + } }] : [], + mcpServers: discovered ? [{ candidateId: 'codex:mcp:docs', definition: { id: { source }, name: 'Docs MCP' } }] : [], + } as unknown as ExternalSourceCatalogSnapshot; +} + +describe('compatibility discovery lifecycle', () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + mocks.getSnapshot.mockReset(); + mocks.ownerSurface = null; + mocks.selectedProductId = 'codex'; + mocks.setOwnerSurface.mockImplementation((owner) => { mocks.ownerSurface = owner; }); + mocks.setOwnerSurface.mockClear(); + mocks.planMcpImport.mockResolvedValue({ schemaVersion: 1, planFingerprint: 'plan', items: [] }); + mocks.applyMcpImport.mockReset(); + mocks.workspacePath = '/workspace'; + mocks.peerDeviceId = ''; + mocks.skills = []; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + async function renderMcp() { + await act(async () => root.render()); + const trigger = container.querySelector('[data-content-group="mcp"] button[aria-expanded]'); + if (trigger?.getAttribute('aria-expanded') === 'false') await act(async () => trigger.click()); + } + + it('shows disabled discovery and its settings action instead of claiming no MCP exists', async () => { + mocks.getSnapshot.mockResolvedValue(snapshot(false)); + await renderMcp(); + expect(container.querySelector('[data-import-kind="mcp"]')?.getAttribute('data-import-state')).toBe('discoveryDisabled'); + const action = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'discovery.manageAction'); + expect(action).toBeDefined(); + await act(async () => action!.click()); + expect(mocks.setOwnerSurface).toHaveBeenCalledWith('external-sources'); + await act(async () => vi.advanceTimersByTimeAsync(5000)); + expect(mocks.getSnapshot).toHaveBeenCalledTimes(1); + }); + + it('collects the completed MCP scan without requiring a manual refresh', async () => { + mocks.getSnapshot.mockResolvedValueOnce(snapshot(true, true)).mockResolvedValue(snapshot(true, false, true)); + await renderMcp(); + expect(container.querySelector('[data-import-kind="mcp"]')?.getAttribute('data-import-state')).toBe('checking'); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(container.querySelector('[data-import-kind="mcp"]')?.getAttribute('data-import-discovered')).toBe('true'); + expect(container.textContent).toContain('Docs MCP'); + await act(async () => vi.advanceTimersByTimeAsync(10000)); + expect(mocks.getSnapshot).toHaveBeenCalledTimes(2); + expect(mocks.getSnapshot).toHaveBeenLastCalledWith('/workspace', false); + }); + + it('ignores a late scan from the previous workspace', async () => { + let resolveOld: (value: ExternalSourceCatalogSnapshot) => void = () => {}; + mocks.getSnapshot.mockResolvedValueOnce(snapshot(true, true)).mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve; })); + await renderMcp(); + await act(async () => vi.advanceTimersByTimeAsync(300)); + mocks.workspacePath = '/other-workspace'; + mocks.getSnapshot.mockResolvedValue(snapshot(false)); + await renderMcp(); + await act(async () => resolveOld(snapshot(true, false, true))); + expect(container.textContent).not.toContain('Docs MCP'); + expect(container.querySelector('[data-import-kind="mcp"]')?.getAttribute('data-import-state')).toBe('discoveryDisabled'); + }); + + it('drops local catalog content immediately when switching to a peer on the same path', async () => { + mocks.getSnapshot.mockResolvedValueOnce(snapshot(true, false, true)); + await renderMcp(); + expect(container.textContent).toContain('Docs MCP'); + let resolvePeer: (value: ExternalSourceCatalogSnapshot) => void = () => {}; + mocks.getSnapshot.mockImplementationOnce(() => new Promise((resolve) => { resolvePeer = resolve; })); + mocks.peerDeviceId = 'peer-host'; + await renderMcp(); + expect(container.textContent).not.toContain('Docs MCP'); + await act(async () => resolvePeer(snapshot(true))); + expect(container.textContent).not.toContain('Docs MCP'); + expect(mocks.getSnapshot).toHaveBeenCalledTimes(2); + }); + + it('distinguishes capability policy, failed reads and a completed empty scan', () => { + const value = snapshot(true); + expect(catalogDiscoveryState(value, 'codex', 'mcp')).toBe('notDetected'); + value.integrationPolicy.effective.ecosystems.codex.capabilities.mcp = 'disabled'; + expect(catalogDiscoveryState(value, 'codex', 'mcp')).toBe('discoveryDisabled'); + value.integrationPolicy.status = 'incompatible_schema'; + expect(catalogDiscoveryState(value, 'codex', 'mcp')).toBe('discoveryUnavailable'); + }); + + it('keeps content-only ecosystems usable without promising an unavailable ACP runtime', async () => { + mocks.selectedProductId = 'pi'; + mocks.getSnapshot.mockResolvedValue(snapshot(true)); + await renderMcp(); + expect(container.querySelector('[data-external-agent-content="pi"]')).not.toBeNull(); + expect(container.textContent).not.toContain('run.description'); + expect(container.textContent).not.toContain('run.openManager'); + expect(container.textContent).not.toContain('header.notAvailable'); + }); + + it('counts external Skills once, preserves their summary in settings, and drops it on host changes', async () => { + mocks.skills = [ + { key: 'project::codex::sample', name: 'sample', sourceId: 'codex', sourceSlot: 'codex', path: '/workspace/.codex/skills/sample' }, + { key: 'project::openbitfun::sample', name: 'sample', sourceId: 'openbitfun', sourceSlot: 'openbitfun', path: '/workspace/.openbitfun/skills/sample', importOrigin: { sourceId: 'codex' } }, + ]; + mocks.getSnapshot.mockResolvedValue(snapshot(true, false, true)); + await renderMcp(); + expect(container.textContent).toContain('assets:2'); + expect(container.textContent).toContain('host.local'); + expect(container.textContent).not.toContain('host.remote'); + mocks.ownerSurface = 'external-sources'; + await act(async () => root.render()); + expect(container.textContent).toContain('assets:2'); + mocks.peerDeviceId = 'different-host'; + mocks.getSnapshot.mockResolvedValue(snapshot(true)); + await act(async () => root.render()); + expect(container.textContent).toContain('assets:0'); + }); +}); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts index 0a719ae28f..b2252a41ad 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts @@ -36,14 +36,18 @@ describe('ecosystem compatibility scene presentation contract', () => { const model = source('./ecosystemCompatibilityModel.ts'); expect(scene).toContain('externalSourcesAPI.getSnapshot(workspacePath, forceRefresh)'); - expect(scene).toContain('externalSourcesAPI.planMcpImport(workspacePath || undefined)'); - expect(scene).toContain('externalSourcesAPI.applyMcpImport('); + const content = source('./ExternalAgentContent.tsx'); + expect(content).toContain('externalSourcesAPI.planMcpImport(workspacePath || undefined)'); + expect(content).toContain('externalSourcesAPI.applyMcpImport('); expect(scene).toContain('ACPClientAPI.getClients()'); expect(scene).toContain('ACPClientAPI.updateClientSubagentConfig({'); - expect(scene).toContain("import('@/infrastructure/config/components/ExternalSourcesConfig')"); - expect(scene).toContain('presentation="governance"'); + expect(scene).not.toContain("import('@/infrastructure/config/components/ExternalSourcesConfig')"); + expect(scene).toContain(''); + expect(scene).toContain(' { expect(scene).not.toContain("openScene('settings')"); expect(model).toContain("id: 'pi'"); expect(model).toContain("pi: ['skill', 'hook']"); - expect(model).toContain("dsh: ['skill', 'hook']"); + expect(model).toContain("dsh: ['skill', 'hook', 'mcp']"); }); it('retires the duplicate Settings page and redirects legacy management links', () => { @@ -174,10 +178,10 @@ describe('ecosystem compatibility scene presentation contract', () => { )).toBe(true); const scene = source('./EcosystemCompatibilityScene.tsx'); - expect(scene).toContain("dimmed ? ' is-disabled' : ''"); - expect(scene).toContain('disabled={!ready || importing}'); - expect(scene).toContain('data-import-support={item.support}'); - expect(scene).toContain('data-import-discovered={item.discovered'); + const content = source('./ExternalAgentContent.tsx'); + expect(content).toContain('disabled={confirmDisabled}'); + expect(content).toContain('data-import-support={item.support}'); + expect(content).toContain('data-import-discovered={item.discovered'); expect(scene).not.toContain("'notApplicable'"); expect(scene).not.toContain('getWorkspaceReferences'); expect(ECOSYSTEM_IMPORT_ITEM_KINDS).not.toContain('reference'); @@ -255,14 +259,14 @@ describe('ecosystem compatibility scene presentation contract', () => { hook: 'adapted', }); expect(piSupport).not.toHaveProperty('pet'); - expect(support('dsh')).toMatchObject({ skill: 'adapted', hook: 'adapted' }); + expect(support('dsh')).toMatchObject({ skill: 'adapted', hook: 'adapted', mcp: 'adapted' }); }); it('keeps use and import in one page with a compact header check summary', () => { const scene = source('./EcosystemCompatibilityScene.tsx'); const model = source('./ecosystemCompatibilityModel.ts'); - const runPosition = scene.indexOf('{renderRun()}'); - const importPosition = scene.indexOf('{renderImport()}'); + const runPosition = scene.lastIndexOf('renderRun()'); + const importPosition = scene.indexOf(' { expect(styles).not.toContain('max-width: 68ch;'); expect(zhCN).toContain('"title": "导入与复用"'); expect(zhCN).not.toMatch(/真实能力|适配范围|全部对象|直接导入链路|能力模块|第二套客户端状态/); - expect(scene).toContain('!ready && !importing ? ('); - expect(scene).toContain('ecosystem-compatibility__import-action-placeholder'); - expect(scene).toContain(' -'); + const content = source('./ExternalAgentContent.tsx'); + expect(content).toContain("t('content.prepareImport')"); + expect(content).toContain("t('content.setupRequired')"); + expect(scene).not.toContain('ecosystem-compatibility__import-action-placeholder'); }); it('lets the page inherit the surrounding scene surface', () => { diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss index 7735099b9a..09d7aecd66 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss @@ -312,6 +312,8 @@ } &__import-table { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 0.85fr) minmax(9rem, max-content); border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); border-radius: var(--openbitfun-radius-lg); overflow: hidden; @@ -319,7 +321,8 @@ &__import-row { display: grid; - grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 0.85fr) 76px; + grid-column: 1 / -1; + grid-template-columns: subgrid; align-items: center; gap: var(--openbitfun-space-4); min-inline-size: 0; @@ -350,7 +353,7 @@ line-height: var(--openbitfun-type-meta-line-height); > span:last-child { - text-align: end; + text-align: center; } } } @@ -391,6 +394,10 @@ } small { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-meta-line-height); @@ -415,13 +422,14 @@ &__import-action { display: flex; + flex-wrap: nowrap; + gap: var(--openbitfun-space-2); align-items: center; justify-content: flex-end; - } - &__import-action-placeholder { - padding-inline: var(--openbitfun-space-3); - color: var(--openbitfun-color-content-muted); + > * { + flex: none; + } } &__runtime-list { @@ -684,8 +692,11 @@ gap: var(--openbitfun-space-3); } - &__import-row { + &__import-table { grid-template-columns: minmax(0, 1fr) auto; + } + + &__import-row { gap: var(--openbitfun-space-3); &--header { @@ -700,12 +711,13 @@ } &__import-item, + &__import-item-copy, &__import-source { grid-column: 1 / -1; } &__import-source { - padding-inline-start: calc(var(--openbitfun-control-height-sm) + var(--openbitfun-space-3)); + padding-inline-start: 0; } &__import-mobile-label { @@ -720,3 +732,244 @@ } } } + +.ecosystem-compatibility { + &__content-overview { + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-lg); + overflow: hidden; + min-inline-size: 0; + } + + &__import-row > &__import-action { + justify-content: center; + } + + &__selection-cell { + display: flex; + align-items: center; + gap: var(--openbitfun-space-3); + + > [data-openbitfun-component="checkbox"] { flex: none; margin: 0; } + > span { min-inline-size: 0; } + } + + &__content-group + &__content-group { + border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + } + + &__content-summary { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) 9rem; + align-items: center; + gap: var(--openbitfun-space-4); + padding: var(--openbitfun-space-5) var(--openbitfun-space-4); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + + > span { + min-inline-size: 0; + grid-column: auto; + } + + &--header { + padding-block: var(--openbitfun-space-3); + background: var(--openbitfun-color-surface-tertiary); + color: var(--openbitfun-color-content-muted); + border-block-end: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + font-size: var(--openbitfun-type-meta-font-size); + + > span:last-child { + text-align: end; + padding-inline-end: calc(var(--openbitfun-control-height-sm) + var(--openbitfun-space-2)); + } + } + } + + &__content-summary-state { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--openbitfun-control-height-sm); + align-items: center; + justify-items: end; + gap: var(--openbitfun-space-2); + } + + &__content-expanded { + padding: 0 var(--openbitfun-space-4) var(--openbitfun-space-4); + } + + &__content-list { + max-block-size: min(28rem, 55dvh); + min-inline-size: 0; + } + + &__content-filters { + display: flex; + align-items: center; + gap: var(--openbitfun-space-3); + margin-block-end: var(--openbitfun-space-4); + flex-wrap: wrap; + } + + &__content-detail { + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-5); + min-inline-size: 0; + max-inline-size: 100%; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + overflow-wrap: anywhere; + + p, h3, h4, dl, pre { margin: 0; } + > * { min-inline-size: 0; max-inline-size: 100%; } + > label { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--openbitfun-space-3); + > span:first-child { flex-shrink: 0; } + } + + pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + dd { + margin-inline-start: 0; + margin-block-end: var(--openbitfun-space-3); + } + } +} + +@container ecosystem-content (max-width: 520px) { + .ecosystem-compatibility__content-summary { + grid-template-columns: minmax(0, 1fr) auto; + + > span:first-child { + grid-column: 1 / -1; + } + + &--header { + display: none; + } + } + +} + +.ecosystem-compatibility { + &__batch-dialog { + block-size: min(42rem, calc(100dvh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter))); + } + + &__batch-status { + flex: 0 0 auto; + padding: 0 var(--openbitfun-space-6) var(--openbitfun-space-4); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + overflow-wrap: anywhere; + p { margin: 0; } + } + + &__batch-body { min-block-size: 0; overflow-y: auto; } + + &__batch-types { + display: flex; + flex-wrap: wrap; + gap: var(--openbitfun-space-2); + margin-block-start: var(--openbitfun-space-3); + } + + &__batch-progress { + display: grid; + gap: var(--openbitfun-space-2); + margin-block-start: var(--openbitfun-space-3); + > div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--openbitfun-space-2); } + progress { + appearance: none; + display: block; + inline-size: 100%; + block-size: var(--openbitfun-space-2); + border: 0; + border-radius: var(--openbitfun-radius-base); + overflow: hidden; + background: var(--openbitfun-color-surface-tertiary); + &::-webkit-progress-bar { background: var(--openbitfun-color-surface-tertiary); } + &::-webkit-progress-value { background: var(--openbitfun-color-content-primary); } + &::-moz-progress-bar { background: var(--openbitfun-color-content-primary); } + } + } + + &__batch-group { + display: grid; + gap: var(--openbitfun-space-3); + > h3 { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-label-md-font-size); + > span:last-child { margin-inline-start: auto; color: var(--openbitfun-color-content-muted); } + } + } + + &__detail-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--openbitfun-space-3); + } + + &__detail-name { + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-label-md-font-size); + } + + &__review-section { + display: grid; + gap: var(--openbitfun-space-2); + padding-block-start: var(--openbitfun-space-4); + border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + > [data-openbitfun-component='status-pill'] { justify-self: start; } + + > h3, > strong { + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + } + } + + &__path { color: var(--openbitfun-color-content-muted); overflow-wrap: anywhere; } + + &__target-field { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--openbitfun-space-2); + + > label { + color: var(--openbitfun-color-content-primary); + font-weight: var(--openbitfun-type-label-selected-font-weight); + } + > * { min-inline-size: 0; max-inline-size: 100%; box-sizing: border-box; } + } + + &__metadata { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--openbitfun-space-2) var(--openbitfun-space-4); + } + + &__feedback { + padding: var(--openbitfun-space-3) var(--openbitfun-space-4); + border-radius: var(--openbitfun-radius-base); + background: var(--openbitfun-color-surface-tertiary); + overflow-wrap: anywhere; + + &--error { + color: var(--openbitfun-color-status-danger-content); + border-inline-start: var(--openbitfun-border-width-default) solid currentColor; + } + } +} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx index 6ab69380e9..285bda4e60 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx @@ -15,16 +15,14 @@ import { StatusPill, Switch, Textarea, - type IconSource, type StatusPillTone, } from '@openbitfun/ui'; import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Bot, CircleUserRound, Network, Package, PawPrint, Server, Webhook, Wrench } from 'lucide-react'; +import { Bot, Network } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { externalSourcesAPI, - type ExternalMcpImportPlanV1, type ExternalSourceCatalogSnapshot, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { @@ -33,26 +31,22 @@ import { } from '@/infrastructure/api/service-api/ACPClientAPI'; import { useNotification } from '@/shared/notification-system'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { isTauriRuntime } from '@/infrastructure/runtime'; import { WorkspaceKind } from '@/shared/types'; import { - buildEcosystemImportItems, buildEcosystemProductRuntimes, totalDiscoveredAssets, - type EcosystemImportItem, - type EcosystemImportItemKind, type EcosystemProductId, type EcosystemProductRuntime, } from './ecosystemCompatibilityModel'; +import ExternalAgentContent from './ExternalAgentContent'; import { useEcosystemCompatibilityStore } from './ecosystemCompatibilityStore'; import './EcosystemCompatibilityScene.scss'; const AcpAgentsConfig = lazy( () => import('@/infrastructure/config/components/AcpAgentsConfig'), ); -const ExternalSourcesConfig = lazy( - () => import('@/infrastructure/config/components/ExternalSourcesConfig'), -); - +const ExternalAgentDiscovery = lazy(() => import('./ExternalAgentDiscovery')); const PRODUCT_ICON_SOURCES: Record = { 'claude-code': '/assets/ecosystem-compatibility/claude-code.svg', codex: '/assets/ecosystem-compatibility/codex.svg', @@ -78,20 +72,6 @@ function EcosystemProductIcon({ productId, size }: { ); } -const IMPORT_ITEM_ICONS: Record = { - account: { glyph: CircleUserRound }, - settings: { name: 'settings' }, - command: { name: 'command-mac' }, - tool: { glyph: Wrench }, - subagent: { glyph: Bot }, - skill: { glyph: Package }, - mcp: { glyph: Server }, - hook: { glyph: Webhook }, - memory: { name: 'thinking' }, - plugin: { name: 'extension' }, - pet: { glyph: PawPrint }, -}; - const GROUP_ORDER = ['connected', 'available', 'other'] as const; const PRODUCT_STATUS_TONES: Record = { connected: 'success', @@ -102,31 +82,6 @@ const PRODUCT_STATUS_TONES: Record = { - ready: 'success', - readyRename: 'success', - checking: 'info', - imported: 'success', - reusable: 'success', - adapted: 'success', - notDetected: 'neutral', - notAdapted: 'neutral', - unsupportedContext: 'warning', - unavailable: 'warning', -}; - interface AcpSubagentDraft { enabled: boolean; description: string; @@ -152,12 +107,22 @@ const EcosystemCompatibilityScene: React.FC = () => { const notification = useNotification(); const { workspace, workspacePath } = useCurrentWorkspace(); const peerDevice = usePeerDeviceModeOptional(); + const peerDeviceId = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : undefined; + const requestScope = JSON.stringify([peerDeviceId, workspace?.id, workspace?.workspaceKind, workspacePath]); const requestSequence = useRef(0); - const importPlanSequence = useRef(0); - const importActionSequence = useRef(0); const contentRef = useRef(null); - const [snapshot, setSnapshot] = useState(null); - const [acpClients, setAcpClients] = useState([]); + const [snapshotState, setSnapshotState] = useState<{ scope: string; value: ExternalSourceCatalogSnapshot | null }>(); + const [clientState, setClientState] = useState<{ scope: string; value: AcpClientInfo[] }>(); + const [supplementalState, setSupplementalState] = useState<{ scope: string; counts: Record }>(); + const supplementalCounts = supplementalState?.scope === requestScope ? supplementalState.counts : undefined; + const onSupplementalCounts = useCallback((counts: Record) => { + setSupplementalState((current) => current?.scope === requestScope + && JSON.stringify(current.counts) === JSON.stringify(counts) ? current : { scope: requestScope, counts }); + }, [requestScope]); + const snapshot = snapshotState?.scope === requestScope ? snapshotState.value : null; + const acpClients = useMemo(() => clientState?.scope === requestScope ? clientState.value : [], [clientState, requestScope]); + const setSnapshot = useCallback((value: ExternalSourceCatalogSnapshot | null) => setSnapshotState({ scope: requestScope, value }), [requestScope]); + const setAcpClients = useCallback((value: AcpClientInfo[]) => setClientState({ scope: requestScope, value }), [requestScope]); const [loadIssues, setLoadIssues] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -165,9 +130,6 @@ const EcosystemCompatibilityScene: React.FC = () => { const ownerSurface = useEcosystemCompatibilityStore((state) => state.ownerSurface); const selectProduct = useEcosystemCompatibilityStore((state) => state.selectProduct); const setOwnerSurface = useEcosystemCompatibilityStore((state) => state.setOwnerSurface); - const [mcpImportPlan, setMcpImportPlan] = useState(null); - const [mcpImportPlanState, setMcpImportPlanState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); - const [importingCandidateId, setImportingCandidateId] = useState(null); const [editingSubagentClientId, setEditingSubagentClientId] = useState(null); const [savingSubagentClientId, setSavingSubagentClientId] = useState(null); const [subagentDraft, setSubagentDraft] = useState({ @@ -176,15 +138,18 @@ const EcosystemCompatibilityScene: React.FC = () => { bestFor: '', }); - const loadCompatibility = useCallback(async (forceRefresh: boolean) => { + const loadCompatibility = useCallback(async ( + forceRefresh: boolean, + backgroundRequest?: { isCurrent: () => boolean }, + ) => { const sequence = ++requestSequence.current; - if (!forceRefresh) setLoading(true); + if (!forceRefresh && !backgroundRequest) setLoading(true); const [sourceResult, clientsResult] = await Promise.allSettled([ externalSourcesAPI.getSnapshot(workspacePath, forceRefresh), ACPClientAPI.getClients(), ]); - if (sequence !== requestSequence.current) return; + if (sequence !== requestSequence.current || backgroundRequest?.isCurrent() === false) return undefined; const nextIssues: LoadIssue[] = []; if (sourceResult.status === 'fulfilled') { @@ -199,7 +164,8 @@ const EcosystemCompatibilityScene: React.FC = () => { } setLoadIssues(nextIssues); setLoading(false); - }, [workspacePath]); + return sourceResult.status === 'fulfilled' ? sourceResult.value : undefined; + }, [setAcpClients, setSnapshot, workspacePath]); useEffect(() => { setSnapshot(null); @@ -209,7 +175,28 @@ const EcosystemCompatibilityScene: React.FC = () => { return () => { requestSequence.current += 1; }; - }, [loadCompatibility]); + }, [loadCompatibility, setAcpClients, setSnapshot]); + + useEffect(() => { + if (!snapshot?.discoveryPending) return undefined; + let cancelled = false; + let timer: number | undefined; + let attempt = 0; + const poll = () => { + const delays = [300, 750, 1500, 3000]; + timer = window.setTimeout(async () => { + const next = await loadCompatibility(false, { isCurrent: () => !cancelled }); + if (cancelled || (next && !next.discoveryPending)) return; + attempt += 1; + poll(); + }, delays[Math.min(attempt, delays.length - 1)]); + }; + poll(); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [loadCompatibility, ownerSurface, snapshot?.discoveryPending]); useEffect(() => { const refreshClients = () => { @@ -224,29 +211,15 @@ const EcosystemCompatibilityScene: React.FC = () => { }, [loadCompatibility]); const productRuntimes = useMemo( - () => buildEcosystemProductRuntimes(snapshot, acpClients), - [acpClients, snapshot], + () => buildEcosystemProductRuntimes(snapshot, acpClients).map((runtime): EcosystemProductRuntime => ( + runtime.status === 'available' && (supplementalCounts?.[runtime.spec.ecosystemId] ?? 0) > 0 + ? { ...runtime, status: 'detected', group: 'connected' } : runtime + )), + [acpClients, snapshot, supplementalCounts], ); const selectedRuntime = productRuntimes.find( (runtime) => runtime.spec.id === selectedProductId, ) ?? productRuntimes[0]; - const importItems = useMemo( - () => selectedRuntime - ? buildEcosystemImportItems(snapshot, selectedRuntime) - : [], - [selectedRuntime, snapshot], - ); - const hasMcpImportItems = importItems.some( - (item) => item.kind === 'mcp' && item.discovered, - ); - const peerDeviceId = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : undefined; - const externalHostReadOnly = snapshot !== null - && !snapshot.hostCapabilities.canMutatePolicy - && !snapshot.hostCapabilities.canManageSources - && !snapshot.hostCapabilities.canApproveRuntime; - const mcpImportSupported = !peerDeviceId - && workspace?.workspaceKind !== WorkspaceKind.Remote - && !externalHostReadOnly; const normalizedSearch = searchQuery.trim().toLowerCase(); const filteredRuntimes = productRuntimes.filter((runtime) => { if (!normalizedSearch) return true; @@ -267,38 +240,6 @@ const EcosystemCompatibilityScene: React.FC = () => { if (contentRef.current) contentRef.current.scrollTop = 0; }, [selectedProductId]); - useEffect(() => { - const sequence = ++importPlanSequence.current; - setMcpImportPlan(null); - - if (!hasMcpImportItems || !mcpImportSupported) { - setMcpImportPlanState('idle'); - return undefined; - } - - setMcpImportPlanState('loading'); - void externalSourcesAPI.planMcpImport(workspacePath || undefined) - .then((plan) => { - if (sequence !== importPlanSequence.current) return; - setMcpImportPlan(plan); - setMcpImportPlanState('ready'); - }) - .catch(() => { - if (sequence !== importPlanSequence.current) return; - setMcpImportPlan(null); - setMcpImportPlanState('failed'); - }); - - return () => { - importPlanSequence.current += 1; - }; - }, [hasMcpImportItems, mcpImportSupported, selectedProductId, snapshot?.generation, workspacePath]); - - useEffect(() => { - importActionSequence.current += 1; - setImportingCandidateId(null); - }, [selectedProductId, workspacePath]); - const showDevelopmentNotice = useCallback((name: string) => { notification.info(t('comingSoon.notice', { name }), { title: t('comingSoon.title'), @@ -365,87 +306,37 @@ const EcosystemCompatibilityScene: React.FC = () => { } }, [loadCompatibility, notification, savingSubagentClientId, subagentDraft, t]); - const handleImportItem = useCallback(async (item: EcosystemImportItem) => { - if (!item.candidateId || !mcpImportSupported || !mcpImportPlan) return; - const planItem = mcpImportPlan.items.find( - (candidate) => candidate.candidateId === item.candidateId, - ); - if (!planItem || !['eligible', 'automatic_rename'].includes(planItem.disposition)) return; - - const sequence = ++importActionSequence.current; - setImportingCandidateId(item.candidateId); - try { - const result = await externalSourcesAPI.applyMcpImport( - workspacePath || undefined, - mcpImportPlan, - [{ candidateId: item.candidateId }], - ); - if (sequence !== importActionSequence.current) return; - - if (result.outcome.status === 'stale') { - setMcpImportPlan(result.outcome.refreshedPlan); - notification.info(t('import.notifications.stale'), { duration: 3200 }); - return; - } - - setMcpImportPlan((current) => current ? { - ...current, - items: current.items.map((candidate) => ( - candidate.candidateId === item.candidateId - ? { ...candidate, disposition: 'already_imported' } - : candidate - )), - } : current); - notification.success(t('import.notifications.success', { name: item.name })); - await loadCompatibility(true); - } catch { - if (sequence === importActionSequence.current) { - notification.error(t('import.notifications.failed', { name: item.name })); - } - } finally { - if (sequence === importActionSequence.current) { - setImportingCandidateId(null); - } - } - }, [loadCompatibility, mcpImportPlan, mcpImportSupported, notification, t, workspacePath]); - if (!selectedRuntime) return null; - const discoveredAssetCount = totalDiscoveredAssets(selectedRuntime.capabilityCounts); - const currentHost = selectedRuntime.executionDomainId - ?? (workspace?.sshHost - ? t('host.remote', { name: workspace.sshHost }) - : t('host.local')); + const discoveredAssetCount = totalDiscoveredAssets(selectedRuntime.capabilityCounts) + + (supplementalCounts?.[selectedRuntime.spec.ecosystemId] ?? 0); + const currentHost = workspace?.workspaceKind === WorkspaceKind.Remote + ? t('host.remote', { name: workspace.sshHost || workspace.name }) + : peerDevice?.peerMode.active + ? t('host.remote', { name: peerDevice.peerMode.deviceName }) + : isTauriRuntime() ? t('host.local') : t('host.remote', { name: window.location.hostname }); const adapterLabel = selectedRuntime.adapterRevision ? t('header.adapterRevision', { revision: selectedRuntime.adapterRevision }) : selectedRuntime.spec.acpClientId ? t('header.acpRuntime') - : t('header.notAvailable'); + : null; const headerCheckSummary = t('header.checksSummary', { sourceCount: formatNumber(selectedRuntime.sources.length), assetCount: formatNumber(discoveredAssetCount), runtimeCount: formatNumber(selectedRuntime.acpClients.length), }); - - const importItemState = (item: EcosystemImportItem): ImportItemState => { - if (item.support === 'notAdapted') return 'notAdapted'; - if (!item.discovered && item.detection === 'owner') return 'adapted'; - if (!item.discovered) return 'notDetected'; - if (!item.nativeImportSupported) return 'reusable'; - if (!mcpImportSupported) return 'unsupportedContext'; - if (mcpImportPlanState === 'loading' || mcpImportPlanState === 'idle') return 'checking'; - if (mcpImportPlanState === 'failed' || !item.candidateId) return 'unavailable'; - const planItem = mcpImportPlan?.items.find( - (candidate) => candidate.candidateId === item.candidateId, - ); - if (planItem?.disposition === 'eligible') return 'ready'; - if (planItem?.disposition === 'automatic_rename') return 'readyRename'; - if (planItem?.disposition === 'already_imported') return 'imported'; - return 'unavailable'; - }; + const sourceLocationFallback = snapshot?.integrationPolicy.status === 'compatible' + && !snapshot.integrationPolicy.effective.enabled + ? t('import.states.discoveryDisabled') + : loading || snapshot?.discoveryPending + ? t('loading') + : loadIssues.includes('externalSources') + ? t('import.states.discoveryUnavailable') + : t('header.notDetected'); const renderProductSummary = (runtime: EcosystemProductRuntime): string => { - const assetCount = totalDiscoveredAssets(runtime.capabilityCounts); + const assetCount = totalDiscoveredAssets(runtime.capabilityCounts) + + (supplementalCounts?.[runtime.spec.ecosystemId] ?? 0); if (runtime.spec.development) return t('productSummary.development'); if (assetCount > 0) { return t('productSummary.assets', { count: formatNumber(assetCount) }); @@ -456,106 +347,6 @@ const EcosystemCompatibilityScene: React.FC = () => { return t('productSummary.available'); }; - const renderImport = () => ( -
-
-
-
-

{t('import.title')}

-

{t('import.description', { name: selectedRuntime.spec.name })}

-
-
-
-
- {t('import.columns.item')} - {t('import.columns.source')} - {t('import.columns.state')} - {t('import.columns.action')} -
- {importItems.map((item) => { - const state = importItemState(item); - const itemIcon = IMPORT_ITEM_ICONS[item.kind]; - const ready = state === 'ready' || state === 'readyRename'; - const dimmed = [ - 'notDetected', - 'notAdapted', - 'unsupportedContext', - 'unavailable', - ].includes(state); - const importing = item.candidateId === importingCandidateId; - const capabilityName = t(`capabilities.${item.kind}`); - const itemName = item.discovered ? item.name : capabilityName; - const itemDescription = item.discovered - ? item.description - : state === 'notAdapted' - ? t('import.notAdaptedDescription', { - name: selectedRuntime.spec.name, - type: capabilityName, - }) - : state === 'adapted' - ? t('import.ownerAdaptedDescription', { type: capabilityName }) - : t('import.undetectedDescription'); - return ( -
- - - - {itemName} - - {item.discovered ? {capabilityName} : null} - {itemDescription} - - - - - - {item.sourceName} - {item.sourceLocation ? {item.sourceLocation} : null} - - - - {t(`import.states.${state}`)} - - - - {!ready && !importing ? ( - - ) : ( - - )} - -
- ); - })} -
-
-
- ); - const renderRun = () => (
@@ -762,7 +553,7 @@ const EcosystemCompatibilityScene: React.FC = () => { {t('run.managerScope')}
}> - + client.id), ...(selectedRuntime.spec.acpClientId ? [selectedRuntime.spec.acpClientId] : [])]} /> ) : null} @@ -885,7 +676,7 @@ const EcosystemCompatibilityScene: React.FC = () => { {t(`status.${selectedRuntime.status}`)} - {adapterLabel} + {adapterLabel ? {adapterLabel} : null} + + ) : null} {loading ? ( {t('loading')} @@ -942,22 +744,14 @@ const EcosystemCompatibilityScene: React.FC = () => { ) : null} {ownerSurface === 'external-sources' ? ( -
-
-
- }> - - -
+ }> + + ) : (
{selectedRuntime.spec.development ? ( @@ -969,8 +763,15 @@ const EcosystemCompatibilityScene: React.FC = () => {
) : null} - {renderRun()} - {renderImport()} + {selectedRuntime.spec.acpClientId || selectedRuntime.acpClients.length > 0 ? renderRun() : null} + loadCompatibility(true)} + /> )} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemDialog.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemDialog.test.tsx new file mode 100644 index 0000000000..05293e6199 --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemDialog.test.tsx @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; + +// Model the design-system presence contract: a closed overlay stays mounted for exit. +vi.mock('@openbitfun/ui', () => ({ Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
{children}
})); +import { EcosystemDialog } from './EcosystemDialog'; + +it('retains the last committed title, body and footer throughout exit and replaces them on reopen', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const container = document.createElement('div'); const root = createRoot(container); + const render = async (open: boolean, text: string | null) => act(async () => root.render( {}}>{text ? <>

{text}

Reviewed content

Confirm
: null}
)); + try { + await render(true, 'First'); await render(false, null); + expect(container.querySelector('[data-state="exiting"]')?.textContent).toBe('FirstReviewed contentConfirm'); + await render(false, 'Unrelated update'); + expect(container.textContent).toBe('FirstReviewed contentConfirm'); + await render(true, 'Second'); expect(container.textContent).toBe('SecondReviewed contentConfirm'); + } finally { await act(async () => root.unmount()); vi.unstubAllGlobals(); } +}); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemDialog.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemDialog.tsx new file mode 100644 index 0000000000..7fc5a42e1e --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemDialog.tsx @@ -0,0 +1,9 @@ +import { useLayoutEffect, useRef } from 'react'; +import { Dialog, type DialogProps } from '@openbitfun/ui'; + +/** Keep the last committed content throughout the design system's exit animation. */ +export function EcosystemDialog({ children, open, ...props }: DialogProps) { + const retained = useRef(children); + useLayoutEffect(() => { if (open) retained.current = children; }, [children, open]); + return {open ? children : retained.current}; +} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx new file mode 100644 index 0000000000..5b80740767 --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx @@ -0,0 +1,366 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { buildEcosystemProductRuntimes, type EcosystemProductId } from './ecosystemCompatibilityModel'; + +const mocks = vi.hoisted(() => ({ + deleteSkill: vi.fn(), loadMcp: vi.fn(), saveMcp: vi.fn(), mutateHook: vi.fn(), getSkills: vi.fn(), validateSkill: vi.fn(), addSkill: vi.fn(), getHooks: vi.fn(), getHookCatalog: vi.fn(), + planHook: vi.fn(), applyHook: vi.fn(), planMcp: vi.fn(), applyMcp: vi.fn(), refresh: vi.fn(), + workspacePath: '/project', remote: false, peer: false, skillImportVersion: 0, +})); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key, formatNumber: String }) })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ + workspacePath: mocks.workspacePath, workspace: { workspaceKind: mocks.remote ? 'remote' : 'normal' }, +}) })); +vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => ({ peerMode: { active: mocks.peer } }) })); +vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: { + getSkillScanReport: async (...args: unknown[]) => ({ skills: await mocks.getSkills(...args), diagnostics: [], importOperationsVersion: mocks.skillImportVersion }), validateSkillPath: mocks.validateSkill, addSkill: mocks.addSkill, deleteSkill: mocks.deleteSkill, +} })); +vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ externalHooksAPI: { + getImportSnapshot: mocks.getHooks, getCatalog: mocks.getHookCatalog, planImport: mocks.planHook, applyImport: mocks.applyHook, mutateImport: mocks.mutateHook, +} })); +vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ externalSourcesAPI: { + planMcpImport: mocks.planMcp, applyMcpImport: mocks.applyMcp, +} })); +vi.mock('@/infrastructure/api/service-api/MCPAPI', () => ({ MCPAPI: { loadMCPJsonConfig: mocks.loadMcp, saveMCPJsonConfig: mocks.saveMcp } })); +vi.mock('@openbitfun/ui', () => { + const Wrapper = ({ children }: React.PropsWithChildren) =>
{children}
; + return { + Input: ({ size: _size, ...props }: React.InputHTMLAttributes) => , + Checkbox: ({ size: _size, ...props }: React.InputHTMLAttributes) => , + Button: ({ children, disabled, onClick, 'aria-label': label }: React.ButtonHTMLAttributes) => , + Select: ({ value, options, onValueChange, disabled }: { value: string; options: Array<{ value: string; label: string }>; onValueChange: (value: string) => void; disabled?: boolean }) => , + SearchField: ({ value, onChange }: React.InputHTMLAttributes) => , + Dialog: ({ open, children, onOpenChange }: React.PropsWithChildren<{ open: boolean; onOpenChange: (open: boolean) => void }>) => open ?
{children}
: null, + DialogClose: () => null, DialogFooter: Wrapper, DialogHeader: Wrapper, DialogHeading: Wrapper, DialogTitle: Wrapper, DialogBody: Wrapper, + Icon: () => , + IconButton: ({ icon, ...props }: React.ButtonHTMLAttributes & { icon: React.ReactNode }) => , + ScrollArea: Wrapper, LoadingState: Wrapper, OverflowText: Wrapper, StatusPill: Wrapper, + }; +}); +import ExternalAgentContent from './ExternalAgentContent'; + +function fixture() { + const sources = ['codex', 'claude-code'].map((ecosystemId) => ({ + stableKey: ecosystemId, record: { ecosystemId, key: { providerId: `${ecosystemId}.mcp`, sourceId: 'user' }, + displayName: ecosystemId, location: `/${ecosystemId}`, scope: 'user_global', health: 'available', diagnostics: [] }, + })); + const snapshot = { generation: 1, discoveryPending: false, sources, commands: [], + hostCapabilities: { canMutatePolicy: true, canManageSources: true, canApproveRuntime: true }, + integrationPolicy: { status: 'compatible', effective: { enabled: true, ecosystems: {} }, registeredEcosystems: [] }, + mcpServers: sources.map((source) => ({ candidateId: source.stableKey, definition: { + id: { source: source.record.key, localId: 'docs' }, name: `${source.stableKey}-MCP`, transport: 'local_stdio', + staticStatus: { state: 'ready' }, environmentKeys: [], headerNames: [], commandPreview: 'docs-server', + } })), + } as unknown as ExternalSourceCatalogSnapshot; + const skills = ['codex', 'claude-code', 'openbitfun'].map((sourceId) => ({ + key: sourceId, sourceId, sourceSlot: sourceId, sourceLabel: sourceId, name: `${sourceId}-Skill`, + path: `/${sourceId}/skills/demo`, dirName: sourceId === 'openbitfun' ? 'native' : 'demo', + level: 'user', isBuiltin: false, description: 'Skill description', + })); + const hookSources = ['codex', 'claude-code'].map((ecosystemId) => ({ ecosystemId, + key: { providerId: `${ecosystemId}.hooks`, sourceId: 'user' }, displayName: `${ecosystemId}-Hooks`, + scope: 'user_global', locationHint: `/${ecosystemId}/settings.json`, health: 'available', + })); + const hooks = { schemaVersion: 1, revision: 'r1', imports: [], diagnostics: [], catalog: { + discoveryPending: false, sources: hookSources, providers: [], diagnostics: [], failedProviderIds: [], + entries: hookSources.map((source) => ({ source: source.key, stableKey: source.ecosystemId, nativeEvent: 'Stop', handlerKind: 'command', matcher: { kind: 'any' } })), + } }; + const plan = { schemaVersion: 1, planFingerprint: 'v1', items: sources.map((source) => ({ + candidateId: source.stableKey, disposition: 'eligible', displayName: source.stableKey, proposedNativeId: 'docs', transport: 'local_stdio', + })) }; + const hookPlan = { schemaVersion: 1, source: hookSources[0], disposition: 'import', behaviorVersion: 'v1', planFingerprint: 'h1', skipped: [], + handlers: [{ stableKey: 'stop-hook', event: 'Stop', command: 'echo reviewed-command', dependencies: [] }], + }; + return { snapshot, skills, hooks, plan, hookPlan }; +} + +describe('external agent content and explicit import boundary', () => { + let root: Root; + let container: HTMLDivElement; + let data: ReturnType; + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.resetAllMocks(); + localStorage.clear(); + mocks.remote = false; mocks.peer = false; mocks.workspacePath = '/project'; mocks.skillImportVersion = 0; + data = fixture(); + mocks.getSkills.mockImplementation(async () => [...data.skills]); + mocks.getHooks.mockResolvedValue(data.hooks); + mocks.getHookCatalog.mockResolvedValue(data.hooks.catalog); + mocks.planMcp.mockResolvedValue(data.plan); + mocks.planHook.mockResolvedValue(data.hookPlan); + mocks.validateSkill.mockResolvedValue({ valid: true }); + mocks.addSkill.mockImplementation(async () => { + data.skills.push({ ...data.skills[0], key: 'imported-copy', sourceId: 'openbitfun', sourceSlot: 'openbitfun', path: '/native/skills/demo' }); + return 'ok'; + }); + mocks.deleteSkill.mockImplementation(async () => { data.skills = data.skills.filter((skill) => skill.key !== 'imported-copy'); mocks.getSkills.mockImplementation(async () => [...data.skills]); return 'ok'; }); + mocks.saveMcp.mockResolvedValue({ runtimeApplied: true }); + mocks.loadMcp.mockResolvedValue({ fingerprint: 'native-v1', jsonConfig: JSON.stringify({ mcpServers: { docs: { command: 'docs-server', _openbitfunImport: { sourceCandidateId: 'codex', behaviorVersion: 'v1' } }, keep: { command: 'keep' } } }) }); + mocks.mutateHook.mockResolvedValue(data.hooks); + mocks.applyMcp.mockResolvedValue({ outcome: { status: 'applied' } }); + mocks.applyHook.mockResolvedValue({ outcome: { kind: 'applied', snapshot: data.hooks } }); + mocks.refresh.mockResolvedValue(undefined); + container = document.createElement('div'); document.body.append(container); root = createRoot(container); + }); + afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); + async function render(product: EcosystemProductId = 'codex', catalogFailed = false) { + const runtime = buildEcosystemProductRuntimes(data.snapshot, []).find((entry) => entry.spec.id === product)!; + await act(async () => root.render()); + } + async function click(label: string, kind?: string) { + if (kind && !container.querySelector(`[data-import-kind="${kind}"]`)) await expand(kind); + const region = kind ? container.querySelector(`[data-import-kind="${kind}"][data-import-discovered="true"]`)! : container; + const button = Array.from(region.querySelectorAll('button')).find((candidate) => candidate.textContent === label); + expect(button, label).toBeDefined(); + await act(async () => button!.click()); + } + + async function expand(kind: string) { + const trigger = container.querySelector(`[data-content-group="${kind}"] button[aria-expanded]`)!; + if (trigger.getAttribute('aria-expanded') !== 'true') await act(async () => trigger.click()); + } + + it('reviews a full agent batch once and copies only that agent after confirmation', async () => { + mocks.skillImportVersion = 1; + await render(); + await click('content.importAll'); + expect(mocks.addSkill).not.toHaveBeenCalled(); + expect(mocks.applyMcp).not.toHaveBeenCalled(); + expect(mocks.applyHook).not.toHaveBeenCalled(); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain('echo reviewed-command'); + await click('content.confirm'); + expect(mocks.addSkill).toHaveBeenCalledTimes(1); + expect(mocks.addSkill.mock.calls[0][0].sourceKey).toBe(data.skills[0].key); + expect(mocks.applyMcp.mock.calls[0][2]).toEqual([{ candidateId: 'codex' }]); + expect(mocks.applyHook).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain('content.batchResults'); + }); + + it('groups the review by type and advances progress only when an operation settles', async () => { + mocks.skillImportVersion = 1; + let finishMcp!: (value: unknown) => void; + let failSkill!: (error: Error) => void; + mocks.applyMcp.mockImplementation(() => new Promise((resolve) => { finishMcp = resolve; })); + mocks.addSkill.mockImplementation(() => new Promise((_resolve, reject) => { failSkill = reject; })); + await render(); await click('content.importAll'); + const dialog = container.querySelector('[role="dialog"]')!; + expect([...dialog.querySelectorAll('.ecosystem-compatibility__batch-group')].map((group) => group.getAttribute('aria-label'))).toEqual(['capabilities.skill', 'capabilities.mcp', 'capabilities.hook']); + expect(dialog.querySelector('progress')).toBeNull(); + await click('content.confirm'); + const progress = dialog.querySelector('progress')!; + expect(progress.max).toBe(3); expect(progress.value).toBe(0); + await act(async () => finishMcp({ outcome: { status: 'applied' } })); + expect(progress.value).toBe(1); + expect(dialog.textContent).toContain('content.batchPending'); + await act(async () => failSkill(new Error('Copy permission denied'))); + expect(progress.value).toBe(3); + expect(dialog.textContent).toContain('Copy permission denied'); + expect(dialog.textContent).toContain('content.batchState.failed'); + expect(dialog.textContent).not.toContain('content.batchPending'); + }); + + it('starts with compact categories and mounts only the expanded category', async () => { + await render(); + expect(container.querySelectorAll('[data-content-group]').length).toBeGreaterThan(2); + expect(container.querySelectorAll('[data-import-kind]')).toHaveLength(0); + const overview = container.querySelector('.ecosystem-compatibility__content-overview')!; + expect(overview.querySelectorAll('[role="columnheader"]')).toHaveLength(3); + expect(overview.textContent).toContain('import.columns.state'); + expect(overview.querySelector('[data-content-group="skill"] [data-icon]')).not.toBeNull(); + expect(overview.querySelector('[data-content-group="skill"]')?.textContent).toContain('import.states.adapted'); + expect(overview.querySelector('[data-content-group="account"] button')).toBeNull(); + await expand('skill'); + expect(container.textContent).toContain('codex-Skill'); + expect(container.textContent).not.toContain('codex-MCP'); + await expand('mcp'); + expect(container.textContent).not.toContain('codex-Skill'); + expect(container.textContent).toContain('codex-MCP'); + }); + + it('shows only the selected agent’s content and makes no import on discovery or inspection', async () => { + await render(); + await expand('skill'); expect(container.textContent).toContain('codex-Skill'); await expand('hook'); expect(container.textContent).toContain('codex-Hooks'); await expand('mcp'); expect(container.textContent).toContain('codex-MCP'); + expect(container.textContent).not.toContain('claude-code-Skill'); expect(container.textContent).not.toContain('claude-code-MCP'); expect(container.textContent).not.toContain('claude-code-Hooks'); expect(container.textContent).not.toContain('openbitfun-Skill'); + await click('content.view', 'mcp'); + expect(container.textContent).toContain('docs-server'); + expect(mocks.applyMcp).not.toHaveBeenCalled(); expect(mocks.addSkill).not.toHaveBeenCalled(); expect(mocks.applyHook).not.toHaveBeenCalled(); + await render('claude-code'); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + await expand('skill'); expect(container.textContent).not.toContain('codex-Skill'); expect(container.textContent).toContain('claude-code-Skill'); + }); + + it('requires a second explicit confirmation and sends only the selected MCP candidate', async () => { + await render(); await click('content.prepareImport', 'mcp'); + expect(mocks.applyMcp).not.toHaveBeenCalled(); + await click('content.confirm'); + expect(mocks.applyMcp).toHaveBeenCalledWith('/project', data.plan, [{ candidateId: 'codex' }]); + expect(container.querySelector('[data-import-kind="mcp"]')?.getAttribute('data-import-state')).toBe('imported'); + }); + + it('cancels without copying and rejects an updated MCP plan until reviewed again', async () => { + await render(); await click('content.prepareImport', 'mcp'); await click('content.cancel'); + expect(mocks.applyMcp).not.toHaveBeenCalled(); + const refreshed = { ...data.plan, planFingerprint: 'v2', items: [{ ...data.plan.items[0], disposition: 'unavailable' }] }; + mocks.applyMcp.mockResolvedValueOnce({ outcome: { status: 'stale', refreshedPlan: refreshed } }); + await click('content.prepareImport', 'mcp'); await click('content.confirm'); + expect(container.textContent).toContain('content.stale'); + const confirm = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'content.confirm'); + expect(confirm?.disabled).toBe(true); + expect(mocks.applyMcp).toHaveBeenCalledTimes(1); + }); + + it('copies a selected Skill only after validation, target review and confirmation', async () => { + await render(); await click('content.prepareImport', 'skill'); + expect(mocks.validateSkill).toHaveBeenCalledWith('/codex/skills/demo'); expect(mocks.addSkill).not.toHaveBeenCalled(); + await click('content.confirm'); + expect(mocks.addSkill).toHaveBeenCalledWith({ sourcePath: '/codex/skills/demo', level: 'user', workspacePath: '/project' }); + }); + + it('locks the reviewed Skill target while a confirmed copy is running', async () => { + let finish: (value: string) => void = () => {}; + mocks.addSkill.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await render(); await click('content.prepareImport', 'skill'); await click('content.confirm'); + expect(container.querySelector('[role="dialog"] select')?.disabled).toBe(true); + await act(async () => finish('ok')); + }); + + it('withdraws import actions when the latest external catalog read failed', async () => { + await render(); + await render('codex', true); await expand('mcp'); + const row = container.querySelector('[data-import-kind="mcp"]'); + expect(row?.getAttribute('data-import-state')).toBe('discoveryUnavailable'); + expect(row?.textContent).not.toContain('content.prepareImport'); + expect(mocks.applyMcp).not.toHaveBeenCalled(); + }); + + it('blocks same-directory Skill collisions without claiming the native item was imported', async () => { + data.skills[2].dirName = 'demo'; + await render(); await click('content.prepareImport', 'skill'); + expect(container.textContent).toContain('content.targetExists'); + await click('content.confirm'); expect(mocks.addSkill).not.toHaveBeenCalled(); + expect(container.querySelector('[data-import-kind="skill"]')?.getAttribute('data-import-state')).not.toBe('imported'); + }); + + it('reviews a unique invocation name for a same-name Skill and displays actual import errors', async () => { + mocks.skillImportVersion = 2; + data.skills[2].dirName = 'demo'; + mocks.addSkill.mockRejectedValueOnce(new Error('Skill target already exists with different content')); + await render(); await click('content.prepareImport', 'skill'); + expect(container.querySelector('[role="dialog"] input')?.value).toBe('demo-codex'); + expect(container.querySelector('[role="dialog"]')?.textContent?.match(/Skill description/g)).toHaveLength(1); + await click('content.confirm'); + expect(mocks.addSkill.mock.calls[0][0].targetName).toBe('demo-codex'); + expect(container.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('content.skillNameConflict'); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it('imports only checked rows and can undo the selected committed copy as a batch', async () => { + mocks.skillImportVersion = 1; + mocks.addSkill.mockImplementation(async () => { + data.skills.push(Object.assign({ ...data.skills[0], key: 'imported-copy', sourceId: 'openbitfun', sourceSlot: 'openbitfun', path: '/native/skills/demo' }, { + importOrigin: { schemaVersion: 1, importId: 'selected-copy', sourceKey: 'codex', sourcePath: '/codex/skills/demo', sourceId: 'codex', sourceLabel: 'Codex', sourceSlot: 'codex', fingerprint: 'copy' }, + })); + return 'ok'; + }); + data.skills.push({ ...data.skills[0], key: 'second-skill', name: 'second-skill', dirName: 'second', path: '/codex/skills/second' }); + await render(); await expand('skill'); + const check = container.querySelector('[data-import-kind="skill"] input[type="checkbox"]')!; + await act(async () => check.click()); + await click('content.importSelected'); await click('content.confirm'); + expect(mocks.addSkill).toHaveBeenCalledTimes(1); + expect(mocks.addSkill.mock.calls[0][0].sourceKey).toBe('codex'); + await click('content.close'); + await click('content.undoSelected'); + expect(mocks.deleteSkill).not.toHaveBeenCalled(); + let finishUndo!: (value: string) => void; + mocks.deleteSkill.mockImplementation(() => new Promise((resolve) => { finishUndo = resolve; })); + await click('content.confirmUndo'); + expect(container.querySelector('progress')?.value).toBe(0); + await act(async () => finishUndo('ok')); + expect(container.querySelector('progress')?.value).toBe(1); + expect(mocks.deleteSkill).toHaveBeenCalledTimes(1); + expect(mocks.deleteSkill.mock.calls[0][0].expectedImportId).toBe('selected-copy'); + expect(container.textContent).toContain('content.batchUndoState.removed'); + }); + + it('shows the exact Hook commands and applies only the reviewed external source', async () => { + await render(); await click('content.prepareImport', 'hook'); + expect(mocks.planHook).toHaveBeenCalledWith('/project', data.hookPlan.source.key); + expect(container.textContent).toContain('echo reviewed-command'); expect(mocks.applyHook).not.toHaveBeenCalled(); + await click('content.confirm'); expect(mocks.applyHook).toHaveBeenCalledWith('/project', data.hookPlan); + }); + + it('rejects a Hook preview for a different agent instead of showing or applying it', async () => { + mocks.planHook.mockResolvedValue({ ...data.hookPlan, source: data.hooks.catalog.sources[1] }); + await render(); await click('content.prepareImport', 'hook'); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(container.textContent).toContain('content.previewFailed'); + expect(mocks.applyHook).not.toHaveBeenCalled(); + }); + + it.each(['remote', 'peer'] as const)('keeps %s source previews but gates unsupported imports without a local fallback', async (surface) => { + mocks[surface] = true; + await render(); await expand('skill'); + expect(container.textContent).toContain('codex-Skill'); + expect(container.textContent).not.toContain('content.prepareImport'); + expect(mocks.planMcp).not.toHaveBeenCalled(); expect(mocks.getHooks).not.toHaveBeenCalled(); + expect(mocks.getHookCatalog).toHaveBeenCalledWith('/project', false); + expect(mocks.addSkill).not.toHaveBeenCalled(); expect(mocks.applyMcp).not.toHaveBeenCalled(); + }); + + it('can cancel undo, then removes only the imported MCP copy after confirmation', async () => { + data.plan.items[0].disposition = 'already_imported'; + await render(); await click('content.undo', 'mcp'); + expect(container.textContent).toContain('content.undoWarning'); + expect(mocks.saveMcp).not.toHaveBeenCalled(); + await click('content.cancel'); + expect(mocks.saveMcp).not.toHaveBeenCalled(); + await click('content.undo', 'mcp'); await click('content.confirmUndo'); + expect(mocks.saveMcp).toHaveBeenCalledTimes(1); + expect(JSON.parse(mocks.saveMcp.mock.calls[0][0])).toEqual({ mcpServers: { keep: { command: 'keep' } } }); + expect(mocks.saveMcp.mock.calls[0][1]).toBe('native-v1'); + }); + + it('retains import state after a failed undo and requires a new review', async () => { + data.plan.items[0].disposition = 'already_imported'; + mocks.saveMcp.mockRejectedValue(new Error('stale')); + await render(); await click('content.undo', 'mcp'); await click('content.confirmUndo'); + expect(container.textContent).toContain('content.undoFailed'); + expect(container.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('stale'); + expect(container.querySelector('[data-import-kind="mcp"]')?.getAttribute('data-import-state')).toBe('imported'); + expect(mocks.saveMcp).toHaveBeenCalledTimes(1); + }); + + it('remembers the exact imported Skill across agent switches and allows reimport after undo', async () => { + await render(); await click('content.prepareImport', 'skill'); await click('content.confirm'); + await render('claude-code'); await render(); + await click('content.undo', 'skill'); + expect(container.textContent).toContain('content.nativeCopy'); + expect(mocks.deleteSkill).not.toHaveBeenCalled(); + await click('content.confirmUndo'); + expect(mocks.deleteSkill).toHaveBeenCalledWith({ skillKey: 'imported-copy', workspacePath: '/project' }); + expect(container.querySelector('[data-import-kind="skill"]')?.getAttribute('data-import-state')).toBe('ready'); + }); + + it('removes only the selected Hook import using its reviewed revision', async () => { + const imported = { ...data.hooks, imports: [{ importId: 'codex-hook-copy', source: data.hookPlan.source, enabled: true, behaviorVersion: 'v1', state: 'current' }] }; + mocks.getHooks.mockResolvedValue(imported); + await render(); await click('content.undo', 'hook'); + expect(mocks.mutateHook).not.toHaveBeenCalled(); + await click('content.confirmUndo'); + expect(mocks.mutateHook).toHaveBeenCalledWith('/project', 'r1', { kind: 'remove', importId: 'codex-hook-copy' }); + }); + + it('ignores a pending preview when the user switches to another agent', async () => { + let resolve: (value: { valid: boolean }) => void = () => {}; + mocks.validateSkill.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + await render(); await click('content.prepareImport', 'skill'); await render('claude-code'); + await act(async () => resolve({ valid: true })); + expect(container.querySelector('[role="dialog"]')).toBeNull(); expect(container.textContent).not.toContain('/codex/skills/demo'); expect(mocks.addSkill).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx new file mode 100644 index 0000000000..fa75d32a3e --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx @@ -0,0 +1,686 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { Button, Checkbox, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading, DialogTitle, Icon, IconButton, Input, LoadingState, OverflowText, ScrollArea, SearchField, Select, StatusPill, type IconSource } from '@openbitfun/ui'; +import { EcosystemDialog as Dialog } from './EcosystemDialog'; +import { EcosystemBatchLayout } from './EcosystemBatchLayout'; +import { suggestSkillImportName, importErrorMessage } from './ecosystemSkillImport'; +import { applyEcosystemBatchUndo, type BatchUndoEntry, type BatchUndoResult } from './ecosystemBatchUndo'; +import { Bot, CircleUserRound, Package, PawPrint, Server, Webhook, Wrench } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; +import { useNotification } from '@/shared/notification-system'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { isTauriRuntime } from '@/infrastructure/runtime'; +import { WorkspaceKind } from '@/shared/types'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { externalSourcesAPI, type ExternalMcpImportPlanV1, type ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { externalHooksAPI, type ExternalHookImportPlan, type ExternalHookImportSnapshot, type ExternalHookSource } from '@/infrastructure/api/service-api/ExternalHooksAPI'; +import type { SkillInfo, SkillLevel, SkillScanDiagnostic } from '@/infrastructure/config/types'; +import { getSkillSourceId, isOpenBitFunManagedSkill } from '@/infrastructure/config/skillSourcePresentation'; +import { buildEcosystemImportItems, catalogDiscoveryState, type EcosystemImportItem, type EcosystemImportItemKind, type EcosystemProductRuntime } from './ecosystemCompatibilityModel'; +import { applyImportUndo, matchesSkillReceipt, prepareHookUndo, prepareMcpUndo, readSkillImportReceipt, rememberSkillImport, type ImportUndoReview } from './ecosystemImportUndo'; +import { applyEcosystemBatch, type BatchImportEntry, type BatchImportResult } from './ecosystemBatchImport'; + +interface ContentItem extends EcosystemImportItem { + skill?: SkillInfo; + hookSource?: ExternalHookSource; +} + +const CONTENT_ICONS: Record = { + account: { glyph: CircleUserRound }, + settings: { name: 'settings' }, + command: { name: 'command-mac' }, + tool: { glyph: Wrench }, + subagent: { glyph: Bot }, + skill: { glyph: Package }, + mcp: { glyph: Server }, + hook: { glyph: Webhook }, + memory: { name: 'thinking' }, + plugin: { name: 'extension' }, + pet: { glyph: PawPrint }, +}; + +type Review = + | { kind: 'mcp'; item: ContentItem; plan: ExternalMcpImportPlanV1 } + | { kind: 'skill'; item: ContentItem; skill: SkillInfo; level: SkillLevel; targetName?: string } + | { kind: 'hook'; item: ContentItem; plan: ExternalHookImportPlan }; + +interface Props { + runtime: EcosystemProductRuntime; + snapshot: ExternalSourceCatalogSnapshot | null; + catalogFailed: boolean; + onRefresh: () => Promise; + onSupplementalCounts?: (counts: Record) => void; +} + +/** An external catalog, never an embedded native manager. Mount separately for each host/workspace/agent. */ +export default function ExternalAgentContent({ runtime, snapshot, catalogFailed, onRefresh, onSupplementalCounts }: Props) { + const contentId = useId(); + const { t, formatNumber } = useI18n('scenes/ecosystem-compatibility'); + const notification = useNotification(); + const { workspace, workspacePath } = useCurrentWorkspace(); + const peer = usePeerDeviceModeOptional(); + const localImportSupported = isTauriRuntime() && !peer?.peerMode.active + && workspace?.workspaceKind !== WorkspaceKind.Remote; + const [skills, setSkills] = useState([]); + const [skillImportVersion, setSkillImportVersion] = useState(0); + const [skillDiagnostics, setSkillDiagnostics] = useState([]); + const [hooks, setHooks] = useState(null); + const [loading, setLoading] = useState(true); + const [loadFailures, setLoadFailures] = useState([]); + const [plan, setPlan] = useState(null); + const [planLoading, setPlanLoading] = useState(false); + const [search, setSearch] = useState(''); + const [kind, setKind] = useState(null); + const [detail, setDetail] = useState(null); + const [review, setReview] = useState(null); + const [batch, setBatch] = useState(null); + const [batchSkipped, setBatchSkipped] = useState(0); + const [batchResults, setBatchResults] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [batchUndo, setBatchUndo] = useState(null); + const [batchUndoResults, setBatchUndoResults] = useState(null); + const [undo, setUndo] = useState<{ item: ContentItem; review: ImportUndoReview } | null>(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + const [completed, setCompleted] = useState>(new Set()); + const alive = useRef(false); + const loadSequence = useRef(0); + const reviewSequence = useRef(0); + + const loadSupplemental = useCallback(async (refresh = false) => { + const sequence = ++loadSequence.current; + setLoading(true); + const [skillResult, hookResult] = await Promise.allSettled([ + configAPI.getSkillScanReport({ workspacePath: workspacePath || undefined, forceRefresh: refresh }), + localImportSupported + ? externalHooksAPI.getImportSnapshot(workspacePath || undefined, refresh) + : externalHooksAPI.getCatalog(workspacePath || undefined, refresh).then((catalog) => ({ + schemaVersion: 1 as const, revision: '', catalog, imports: [], diagnostics: [], + })), + ]); + if (!alive.current || sequence !== loadSequence.current) return; + const failures: string[] = []; + if (skillResult.status === 'fulfilled') { + setSkills(skillResult.value.skills); + setSkillImportVersion(skillResult.value.importOperationsVersion ?? 0); + setSkillDiagnostics(skillResult.value.diagnostics.filter((entry) => entry.sourceId === runtime.spec.ecosystemId)); + } else { setSkills([]); setSkillDiagnostics([]); failures.push('skill'); } + if (hookResult.status === 'fulfilled') setHooks(hookResult.value); + else { setHooks(null); failures.push('hook'); } + setLoadFailures(failures); + setLoading(false); + }, [localImportSupported, runtime.spec.ecosystemId, workspacePath]); + + useEffect(() => { + alive.current = true; + void loadSupplemental(); + return () => { alive.current = false; loadSequence.current += 1; reviewSequence.current += 1; }; + }, [loadSupplemental]); + + useEffect(() => { + if (!hooks?.catalog.discoveryPending) return; + const timer = window.setTimeout(() => void loadSupplemental(), 1000); + return () => window.clearTimeout(timer); + }, [hooks?.catalog.discoveryPending, hooks, loadSupplemental]); + + const items = useMemo(() => { + const catalog = buildEcosystemImportItems(snapshot, runtime); + const externalSkills = skills.filter((skill) => !isOpenBitFunManagedSkill(skill) + && getSkillSourceId(skill) === runtime.spec.ecosystemId); + const hookSources = hooks?.catalog.sources.filter((source) => source.ecosystemId === runtime.spec.ecosystemId) ?? []; + return catalog.flatMap((item): ContentItem[] => { + if (item.kind === 'skill' && externalSkills.length > 0) return externalSkills.map((skill) => ({ + ...item, id: `skill:${skill.key}`, name: skill.name, description: skill.description, + sourceName: skill.sourceLabel || runtime.spec.name, sourceLocation: skill.path, + discovered: true, skill, + })); + if (item.kind === 'hook' && hookSources.length > 0) return hookSources.map((source) => ({ + ...item, id: `hook:${source.key.providerId}:${source.key.sourceId}`, name: source.displayName, + sourceName: runtime.spec.name, sourceLocation: source.locationHint, + discovered: true, hookSource: source, + })); + return [item]; + }); + }, [hooks, runtime, skills, snapshot]); + + useEffect(() => { + if (loading) return; + const counts: Record = {}; + for (const skill of skills) { + if (isOpenBitFunManagedSkill(skill)) continue; + const source = getSkillSourceId(skill); + counts[source] = (counts[source] ?? 0) + 1; + } + for (const source of hooks?.catalog.sources ?? []) { + counts[source.ecosystemId] = (counts[source.ecosystemId] ?? 0) + 1; + } + onSupplementalCounts?.(counts); + }, [hooks, loading, onSupplementalCounts, skills]); + + const hasMcp = items.some((item) => item.kind === 'mcp' && item.discovered); + useEffect(() => { + let cancelled = false; + setPlan(null); + if (!localImportSupported || !hasMcp) { setPlanLoading(false); return; } + setPlanLoading(true); + void externalSourcesAPI.planMcpImport(workspacePath || undefined) + .then((next) => { if (!cancelled) setPlan(next); }) + .catch(() => { if (!cancelled) setPlan(null); }) + .finally(() => { if (!cancelled) setPlanLoading(false); }); + return () => { cancelled = true; }; + }, [hasMcp, localImportSupported, snapshot?.generation, workspacePath]); + + const importedHook = (item: ContentItem) => hooks?.imports.some((entry) => ( + entry.source.key.providerId === item.hookSource?.key.providerId + && entry.source.key.sourceId === item.hookSource?.key.sourceId + )); + const skillCollision = (skill: SkillInfo, level: SkillLevel) => skills.some((entry) => ( + isOpenBitFunManagedSkill(entry) && entry.level === level && entry.dirName === skill.dirName + )); + const importedSkill = (item: ContentItem) => { + if (!localImportSupported || !item.skill) return null; + const source = item.skill; + const native = skills.find((entry) => isOpenBitFunManagedSkill(entry) + && entry.importOrigin?.sourceKey === source.key && entry.importOrigin.sourcePath === source.path); + if (native?.importOrigin) return { schemaVersion: 1 as const, sourcePath: source.path, + nativeKey: native.key, nativePath: native.path, level: native.level, + importId: native.importOrigin.importId }; + const receipt = readSkillImportReceipt(item.skill.path, workspacePath || undefined); + return receipt && skills.some((entry) => matchesSkillReceipt(entry, receipt)) ? receipt : null; + }; + const importState = (item: ContentItem): string => { + if (completed.has(item.id) || importedHook(item) || importedSkill(item)) return 'imported'; + if (!item.discovered) { + if (item.kind === 'skill' || item.kind === 'hook') { + if (loading || (item.kind === 'hook' && hooks?.catalog.discoveryPending)) return 'checking'; + const providerFailed = item.kind === 'hook' && hooks?.catalog.providers.some((provider) => provider.ecosystemId === runtime.spec.ecosystemId && hooks.catalog.failedProviderIds.includes(provider.providerId)); + return loadFailures.includes(item.kind) || (item.kind === 'skill' && skillDiagnostics.length > 0) || providerFailed ? 'discoveryUnavailable' : 'notDetected'; + } + if (item.support === 'notAdapted') return 'notAdapted'; + return catalogFailed ? 'discoveryUnavailable' : catalogDiscoveryState(snapshot, runtime.spec.ecosystemId, item.kind); + } + if (item.kind === 'mcp' && plan?.items.find((entry) => entry.candidateId === item.candidateId)?.disposition === 'already_imported') return 'imported'; + if (catalogFailed && !item.skill && !item.hookSource) return 'discoveryUnavailable'; + if (!localImportSupported) return 'unsupportedContext'; + if (item.skill) return skillImportVersion >= 1 || !item.skill.entryFile || item.skill.entryFile === 'SKILL.md' ? 'ready' : 'notAdapted'; + if (item.hookSource) return ['claude-code', 'codex'].includes(item.hookSource.ecosystemId) ? 'review' : 'notAdapted'; + if (item.kind !== 'mcp') return 'notAdapted'; + if (planLoading) return 'checking'; + const disposition = plan?.items.find((entry) => entry.candidateId === item.candidateId)?.disposition; + if (disposition === 'eligible') return 'ready'; + if (disposition === 'automatic_rename') return 'readyRename'; + return 'unavailable'; + }; + const stateDescription = (item: ContentItem, state: string) => { + if (state === 'imported') return t(item.kind === 'mcp' ? 'content.mcpImportedDescription' + : item.kind === 'hook' ? 'content.hookImportedDescription' : 'content.importedDescription'); + if (state === 'unsupportedContext') return t('content.unsupportedContext'); + if (state === 'notAdapted') return t('content.previewOnly'); + if (state === 'discoveryDisabled') return t('discovery.disabledDescription'); + if (state === 'discoveryUnavailable') return t('discovery.unavailableDescription'); + if (state === 'checking') return t('loading'); + if (!item.discovered) return t('import.undetectedDescription'); + if (state === 'unavailable') { + const entry = plan?.items.find((candidate) => candidate.candidateId === item.candidateId); + const definition = snapshot?.mcpServers?.find((server) => server.candidateId === item.candidateId)?.definition; + if (definition?.staticStatus?.state === 'disabled_by_source') return t('content.sourceDisabled'); + return entry?.reasonCode === 'external_mcp.import_setup_required' ? t('content.setupRequired') : t('content.importUnavailable'); + } + return item.description || t('content.externalOnly'); + }; + + async function prepareBatch(group?: EcosystemImportItemKind, selectedOnly = false) { + if (busy || !localImportSupported) return; + setBusy(true); + setNotice(null); + const candidates = items.filter((item) => (!group || item.kind === group) && item.discovered && (!selectedOnly || (selected.has(item.id) + && `${item.name} ${item.description ?? ''} ${item.sourceLocation ?? ''}`.toLowerCase().includes(search.trim().toLowerCase())))); + const entries: BatchImportEntry[] = []; + try { + const freshMcpPlan = candidates.some((item) => item.kind === 'mcp') + ? await externalSourcesAPI.planMcpImport(workspacePath || undefined).catch(() => null) : null; + for (const item of candidates) { + if (!alive.current) return; + if (!['ready', 'readyRename', 'review'].includes(importState(item))) continue; + if (item.skill && skillImportVersion >= 1) { + const level = item.skill.level === 'project' && workspacePath ? 'project' : 'user'; + const reserved = entries.filter((entry) => entry.kind === 'skill' && entry.level === level) + .flatMap((entry) => entry.kind === 'skill' ? [entry.targetName ?? entry.skill.name, entry.targetName ?? entry.skill.dirName] : []); + const targetName = skillImportVersion >= 2 ? suggestSkillImportName(item.skill, level, skills, reserved) : undefined; + entries.push({ id: item.id, name: item.name, kind: 'skill', skill: item.skill, level, targetName }); + } + else if (item.kind === 'mcp' && item.candidateId && freshMcpPlan?.items.some((candidate) => + candidate.candidateId === item.candidateId && ['eligible', 'automatic_rename'].includes(candidate.disposition))) { + entries.push({ id: item.id, name: item.name, kind: 'mcp', candidateId: item.candidateId, plan: freshMcpPlan }); + } else if (item.hookSource) { + const next = await externalHooksAPI.planImport(workspacePath || undefined, item.hookSource.key).catch(() => null); + if (next && next.source.ecosystemId === runtime.spec.ecosystemId + && next.source.key.providerId === item.hookSource.key.providerId + && next.source.key.sourceId === item.hookSource.key.sourceId + && next.disposition !== 'unavailable' && next.handlers.length) { + entries.push({ id: item.id, name: item.name, kind: 'hook', plan: next }); + } + } + } + if (!alive.current) return; + setBatchSkipped(candidates.length - entries.length); + setBatchResults(null); + setBatch(entries); + } finally { if (alive.current) setBusy(false); } + } + + async function confirmBatch() { + if (!batch?.length || busy || !localImportSupported) return; + setBusy(true); + setBatchResults([]); + try { + await applyEcosystemBatch(batch, workspacePath || undefined, (result) => { + if (!alive.current) return; + setBatchResults((current) => [...(current ?? []), result]); + if (result.status === 'imported') setCompleted((current) => new Set([...current, result.id])); + }); + if (alive.current) { + await loadSupplemental(true); + await onRefresh(); + } + } catch { if (alive.current) setNotice(t('content.refreshAfterImportFailed')); } + finally { if (alive.current) setBusy(false); } + } + + async function prepareImport(item: ContentItem) { + const sequence = ++reviewSequence.current; + setNotice(null); + if (!localImportSupported || busy) return; + setBusy(true); + try { + if (item.skill) { + const validation = skillImportVersion >= 1 && item.skill.entryFile && item.skill.entryFile !== 'SKILL.md' + ? { valid: true } : await configAPI.validateSkillPath(item.skill.path); + if (!alive.current || sequence !== reviewSequence.current) return; + if (!validation.valid) { setNotice(t('content.validationFailed')); return; } + const level = item.skill.level === 'project' && workspacePath ? 'project' : 'user'; + setReview({ kind: 'skill', item, skill: item.skill, level, + targetName: skillImportVersion >= 2 ? suggestSkillImportName(item.skill, level, skills) : undefined }); + } else if (item.hookSource) { + const next = await externalHooksAPI.planImport(workspacePath || undefined, item.hookSource.key); + if (!alive.current || sequence !== reviewSequence.current) return; + if (next.source.ecosystemId !== runtime.spec.ecosystemId || next.source.key.providerId !== item.hookSource.key.providerId || next.source.key.sourceId !== item.hookSource.key.sourceId) { setNotice(t('content.previewFailed')); return; } + setReview({ kind: 'hook', item, plan: next }); + } else if (item.kind === 'mcp' && plan) { + setReview({ kind: 'mcp', item, plan }); + } + } catch { + if (alive.current && sequence === reviewSequence.current) setNotice(t('content.previewFailed')); + } finally { + if (alive.current && sequence === reviewSequence.current) setBusy(false); + } + } + + async function confirmImport() { + if (!review || busy || !localImportSupported) return; + const captured = review; + setBusy(true); + setNotice(null); + try { + if (captured.kind === 'skill') { + if (skillImportVersion < 1 && skillCollision(captured.skill, captured.level)) return; + await configAPI.addSkill({ sourcePath: captured.skill.path, level: captured.level, + workspacePath: workspacePath || undefined, + ...(skillImportVersion >= 2 && captured.targetName ? { targetName: captured.targetName } : {}), + ...(skillImportVersion >= 1 ? { sourceKey: captured.skill.key } : {}) }); + // The copy has committed. A failed read-back must not invite a duplicate import. + const report = await configAPI.getSkillScanReport({ workspacePath: workspacePath || undefined, forceRefresh: true }).catch(() => null); + if (!alive.current) return; + const native = report?.skills.find((entry) => isOpenBitFunManagedSkill(entry) + && !entry.isBuiltin && entry.level === captured.level && entry.dirName === (captured.targetName ?? captured.skill.dirName) + && !skills.some((existing) => existing.key === entry.key)); + if (native) rememberSkillImport(captured.skill.path, native, workspacePath || undefined); + if (report) setSkills(report.skills); + } else if (captured.kind === 'mcp') { + const candidateId = captured.item.candidateId!; + const selected = captured.plan.items.find((item) => item.candidateId === candidateId); + if (!selected || !['eligible', 'automatic_rename'].includes(selected.disposition)) return; + const result = await externalSourcesAPI.applyMcpImport(workspacePath || undefined, captured.plan, [{ candidateId }]); + if (!alive.current) return; + if (result.outcome.status === 'stale') { + setPlan(result.outcome.refreshedPlan); + setReview({ ...captured, plan: result.outcome.refreshedPlan }); + setNotice(t('content.stale')); + return; + } + } else { + if (captured.plan.disposition === 'unavailable' || captured.plan.handlers.length === 0) return; + const result = await externalHooksAPI.applyImport(workspacePath || undefined, captured.plan); + if (!alive.current) return; + if (result.outcome.kind === 'stale') { + setReview({ ...captured, plan: result.outcome.refreshedPlan }); + setNotice(t('content.stale')); + return; + } + setHooks(result.outcome.snapshot); + } + if (!alive.current) return; + setCompleted((current) => new Set([...current, captured.item.id])); + setReview(null); + setDetail(null); + notification.success(t('content.importSuccess', { name: captured.item.name }), { duration: 3200 }); + void loadSupplemental(true); + void onRefresh(); + } catch (error) { + const reason = importErrorMessage(error); + const conflict = reason.includes('Skill target already exists with different content') || reason.includes('Skill target belongs to a different import'); + if (alive.current) setNotice(`${t('content.importFailed')} ${conflict ? t('content.skillNameConflict') : reason}`); + } finally { + if (alive.current) setBusy(false); + } + } + + async function prepareUndo(item: ContentItem) { + if (busy || !localImportSupported) return; + const sequence = ++reviewSequence.current; + setBusy(true); + setNotice(null); + try { + const receipt = importedSkill(item); + const next = item.kind === 'mcp' && item.candidateId ? await prepareMcpUndo(item.candidateId) + : item.hookSource ? await prepareHookUndo(item.hookSource, workspacePath || undefined) + : receipt ? { kind: 'skill' as const, target: receipt.nativePath, receipt } : null; + if (!alive.current || sequence !== reviewSequence.current) return; + if (!next) { setNotice(t('content.undoUnavailable')); return; } + setUndo({ item, review: next }); + } catch { + if (alive.current && sequence === reviewSequence.current) setNotice(t('content.undoFailed')); + } finally { + if (alive.current && sequence === reviewSequence.current) setBusy(false); + } + } + + async function confirmUndo() { + if (!undo || busy || !localImportSupported) return; + const captured = undo; + setBusy(true); + setNotice(null); + try { + const result = await applyImportUndo(captured.review, workspacePath || undefined); + if (!alive.current) return; + setCompleted((current) => { const next = new Set(current); next.delete(captured.item.id); return next; }); + setUndo(null); + if (result.runtimeApplied) { + notification.success(t('content.undoSuccess', { name: captured.item.name }), { duration: 3200 }); + } else { + setNotice(t('content.undoRuntimePending', { name: captured.item.name })); + } + // Clear stale imported states immediately, then reload authoritative owners. + if (captured.review.kind === 'hook') { + const importId = captured.review.importId; + setHooks((current) => current ? { ...current, imports: current.imports.filter((entry) => entry.importId !== importId) } : current); + } + if (captured.review.kind === 'skill') { + const nativeKey = captured.review.receipt.nativeKey; + setSkills((current) => current.filter((entry) => entry.key !== nativeKey)); + } + if (captured.review.kind === 'mcp') { + setPlan(null); + setPlanLoading(true); + const next = await externalSourcesAPI.planMcpImport(workspacePath || undefined).catch(() => null); + if (!alive.current) return; + setPlan(next); + setPlanLoading(false); + } + void loadSupplemental(true); + void onRefresh(); + } catch (error) { + if (alive.current) setNotice(`${t('content.undoFailed')} ${importErrorMessage(error)}`); + } finally { + if (alive.current) setBusy(false); + } + } + + async function prepareBatchUndo(group?: EcosystemImportItemKind, selectedOnly = false) { + if (busy || !localImportSupported) return; + setBusy(true); setNotice(null); setBatchUndoResults(null); + try { + const entries: BatchUndoEntry[] = []; + for (const item of items.filter((entry) => (!group || entry.kind === group) && (!selectedOnly || (selected.has(entry.id) + && `${entry.name} ${entry.description ?? ''} ${entry.sourceLocation ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()))) && importState(entry) === 'imported')) { + const receipt = importedSkill(item); + const review = item.kind === 'mcp' && item.candidateId ? await prepareMcpUndo(item.candidateId) + : item.hookSource ? await prepareHookUndo(item.hookSource, workspacePath || undefined) + : receipt ? { kind: 'skill' as const, target: receipt.nativePath, receipt } : null; + if (!alive.current) return; + if (!review) throw new Error(t('content.undoUnavailable')); + entries.push({ id: item.id, name: item.name, review }); + } + setBatchUndo(entries); + } catch (error) { if (alive.current) setNotice(`${t('content.undoFailed')} ${importErrorMessage(error)}`); } + finally { if (alive.current) setBusy(false); } + } + + async function confirmBatchUndo() { + if (!batchUndo?.length || busy || !localImportSupported) return; + setBusy(true); setBatchUndoResults([]); + try { + await applyEcosystemBatchUndo(batchUndo, workspacePath || undefined, (result) => { + if (!alive.current) return; + setBatchUndoResults((current) => [...(current ?? []), result]); + if (result.status !== 'failed') setCompleted((current) => { const next = new Set(current); next.delete(result.id); return next; }); + }); + if (!alive.current) return; + setSelected(new Set()); + await loadSupplemental(true); + setPlan(await externalSourcesAPI.planMcpImport(workspacePath || undefined).catch(() => null)); + await onRefresh(); + } catch (error) { if (alive.current) setNotice(`${t('content.undoFailed')} ${importErrorMessage(error)}`); } + finally { if (alive.current) setBusy(false); } + } + + const reviewMcp = review?.kind === 'mcp' ? review.plan.items.find((entry) => entry.candidateId === review.item.candidateId) : undefined; + const confirmDisabled = busy || !review || (review.kind === 'mcp' + ? !reviewMcp || !['eligible', 'automatic_rename'].includes(reviewMcp.disposition) + : review.kind === 'hook' ? review.plan.disposition === 'unavailable' || review.plan.handlers.length === 0 + : (review.targetName !== undefined && (!/^[a-zA-Z0-9_-]{1,100}$/.test(review.targetName) + || skills.some((entry) => isOpenBitFunManagedSkill(entry) && entry.level === review.level + && [entry.dirName.toLowerCase(), entry.name.toLowerCase()].includes(review.targetName!.toLowerCase())))) + || (skillImportVersion < 1 && skillCollision(review.skill, review.level))); + const visible = items.filter((item) => item.kind === kind + && `${item.name} ${item.description ?? ''} ${item.sourceLocation ?? ''}`.toLowerCase().includes(search.trim().toLowerCase())); + const viewed = review?.item ?? detail; + const mcpDetail = viewed?.kind === 'mcp' ? snapshot?.mcpServers?.find((entry) => entry.candidateId === viewed.candidateId)?.definition : undefined; + const hookEntries = viewed?.hookSource ? hooks?.catalog.entries.filter((entry) => ( + entry.source.providerId === viewed.hookSource!.key.providerId && entry.source.sourceId === viewed.hookSource!.key.sourceId + )) ?? [] : []; + + return ( +
+
+

{t('content.title', { name: runtime.spec.name })}

{t('content.description')}

+
+ {localImportSupported ? <> + : null} + } aria-label={t('content.refresh')} title={t('content.refresh')} disabled={busy || loading} onClick={() => { setNotice(null); void loadSupplemental(true); void onRefresh(); }} /> +
+
+ {notice && !review && !undo && !batch && !batchUndo ?

{notice}

: null} + {loading ? {t('loading')} : null} +
+
+ {t('import.columns.item')} + {t('import.columns.source')} + {t('import.columns.state')} +
+ {Array.from(new Set(items.map((item) => item.kind))).map((group) => { + const groupItems = items.filter((item) => item.kind === group); + const representative = groupItems[0]; + const count = groupItems.filter((item) => item.discovered).length; + const adapted = representative.support === 'adapted'; + const expandable = adapted || count > 0; + const expanded = kind === group; + const groupId = `${contentId}-${group}`; + const description = !adapted + ? t('import.notAdaptedDescription', { name: runtime.spec.name, type: t(`capabilities.${group}`) }) + : count > 0 ? t('content.groupSummary', { count: formatNumber(count) }) + : stateDescription(representative, importState(representative)); + return
+
+ + + + {t(`capabilities.${group}`)} + {description} + + + {runtime.spec.name} + + {t(adapted ? 'import.states.adapted' : 'import.states.notAdapted')} + {expandable ? } + aria-label={t(expanded ? 'content.collapseCategory' : 'content.expandCategory', { type: t(`capabilities.${group}`) })} + aria-expanded={expanded} aria-controls={groupId} + onClick={() => { setKind(expanded ? null : group); setSearch(''); setSelected(new Set()); }} /> : null} + +
+ +
; + })} +
+ { if (!open && !busy) { setBatchUndo(null); setNotice(null); } }} size="lg" closeOnPointerOutside={!busy}> + {t(batchUndoResults ? busy ? 'content.batchUndoing' : 'content.batchUndoResults' : 'content.batchUndoTitle')}{!busy ? : null} + entry.review.kind} busy={busy} processed={batchUndoResults?.length} + summary={batchUndoResults ? t('content.batchUndoSummary', { + removed: formatNumber(batchUndoResults.filter((result) => result.status === 'removed').length), + pending: formatNumber(batchUndoResults.filter((result) => result.status === 'pending').length), + failed: formatNumber(batchUndoResults.filter((result) => result.status === 'failed').length), + }) : t('content.batchUndoWarning', { count: formatNumber(batchUndo?.length ?? 0) })} + renderEntry={(entry) => { + const result = batchUndoResults?.find((item) => item.id === entry.id); + return
{entry.name}

{entry.review.target}

+ {batchUndoResults ? {result ? t(`content.batchUndoState.${result.status}`) : t('content.batchPending')} : null} + {result?.error ?

{result.error}

: null} +
; + }}> + {!batchUndo?.length ?

{t('content.undoEmpty')}

: null} + {notice ?

{notice}

: null} +
+ + {!batchUndoResults ? : null} + +
+ { if (!open && !busy) { setBatch(null); setNotice(null); } }} size="lg" closeOnPointerOutside={!busy}> + {t(batchResults ? busy ? 'content.batchImporting' : 'content.batchResults' : 'content.batchTitle')}{!busy ? : null} + entry.kind} busy={busy} processed={batchResults?.length} + summary={batchResults ? t('content.batchResultSummary', { + imported: formatNumber(batchResults.filter((result) => result.status === 'imported').length), + unresolved: formatNumber(batchResults.filter((result) => result.status !== 'imported').length), + skipped: formatNumber(batchSkipped), + }) : t('content.batchDescription', { count: formatNumber(batch?.length ?? 0), skipped: formatNumber(batchSkipped) })} + renderEntry={(entry) => { + const result = batchResults?.find((item) => item.id === entry.id); + return
{entry.name} + {batchResults ? <> + {entry.kind === 'skill' && entry.targetName ?

{t('content.importName')}: {entry.targetName}

: null} + {result ? t(`content.batchState.${result.status}`) : t('content.batchPending')} + {result?.error ?

{result.error}

: null} + : <>

{entry.kind === 'skill' ? t(entry.level === 'project' ? 'content.projectTarget' : 'content.userTarget') : t('content.nativeUserTarget')}

+ {entry.kind === 'skill' && entry.targetName ?

{t('content.renameNotice', { name: entry.targetName })}

: null} + {entry.kind === 'skill' ?

{entry.skill.path}

: entry.kind === 'hook' ?

{entry.plan.source.locationHint}

: null} + {entry.kind === 'hook' ? <>

{t('content.hookWarning')}

{entry.plan.handlers.map((handler) =>

{handler.event}{handler.matcher ? ` · ${handler.matcher}` : ''}

{handler.command}
{handler.commandWindows ?
{handler.commandWindows}
: null}{handler.dependencies.map((dependency) =>

{dependency.kind === 'managed' ? dependency.relativePath : dependency.location}

)}
)}{entry.plan.skipped.map((entry) =>

{t('content.skipped', { reason: entry.reasonCode, count: formatNumber(entry.count) })}

)} : null} + {entry.kind === 'mcp' ?

{t('content.mcpTarget', { name: entry.plan.items.find((item) => item.candidateId === entry.candidateId)?.proposedNativeId ?? entry.name })}

: null} + } +
; + }}> + {!batch?.length ?

{t('content.batchEmpty')}

: null} + {notice ?

{notice}

: null} +
+ + {!batchResults ? : null} + +
+ { if (!open && !busy) { setUndo(null); setNotice(null); } }} size="md" closeOnPointerOutside={!busy}> + {t('content.undoTitle')}{!busy ? : null} + + {undo ?
+ {undo.item.name}

{t('content.undoWarning')}

+

{t('content.nativeCopy')}

{undo.review.target}

+ {notice ?

{notice}

: null} +
: null} +
+ + + + +
+ { if (!open && !busy) { reviewSequence.current += 1; setReview(null); setDetail(null); setNotice(null); } }} size="lg" closeOnPointerOutside={!busy}> + {review ? t('content.confirmTitle') : viewed?.name}{!busy ? : null} + + {viewed ?
+
{viewed.name}{runtime.spec.name} · {t(`capabilities.${viewed.kind}`)}
+

{t('content.sourceLocation')}

{viewed.sourceLocation}

+ {viewed.description ?

{t('content.descriptionLabel')}

{viewed.description}

: null} + {stateDescription(viewed, importState(viewed)) !== viewed.description ?

{stateDescription(viewed, importState(viewed))}

: null} + {mcpDetail ?
{t('content.transport')}
{mcpDetail.transport}
{t('content.command')}
{mcpDetail.commandPreview ?? mcpDetail.remoteUrlPreview ?? '—'}
{t('content.environmentKeys')}
{mcpDetail.environmentKeys?.join(', ') || '—'}
{t('content.headerNames')}
{mcpDetail.headerNames?.join(', ') || '—'}
: null} + {hookEntries.map((entry) =>

{entry.nativeEvent} · {entry.handlerKind}{entry.matcher.kind === 'pattern' ? ` · ${entry.matcher.display}` : ''}

)} + {review ? <> +

{t('content.copyWarning')}

+ {review.kind === 'mcp' ?

{t('content.mcpTarget', { name: reviewMcp?.proposedNativeId ?? review.item.name })}

: null} + {review.kind === 'skill' ? <> +
{ setNotice(null); setReview({ ...review, targetName: event.target.value }); }} />
: null} + {review.targetName ?

{t('content.renameNotice', { name: review.targetName })}

: null} + {review.targetName !== undefined && confirmDisabled && !busy ?

{t('content.invalidImportName')}

: null} + {skillCollision(review.skill, review.level) && skillImportVersion < 2 ?

{t(skillImportVersion >= 1 ? 'content.targetRepair' : 'content.targetExists')}

: null} + : null} + {review.kind === 'hook' ? <> +

{t('content.hookWarning')}

+ {review.plan.handlers.map((handler) =>

{handler.event}{handler.matcher ? ` · ${handler.matcher}` : ''}

{handler.command}
{handler.commandWindows ?
{handler.commandWindows}
: null}{handler.dependencies.map((dependency) =>

{dependency.kind === 'managed' ? dependency.relativePath : dependency.location}

)}
)} + {review.plan.skipped.map((entry) =>

{t('content.skipped', { reason: entry.reasonCode, count: formatNumber(entry.count) })}

)} + : null} + {notice ?

{notice}

: null} + : null} +
: null} +
+ {review ? + + + : null} +
+
+ ); +} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentDiscovery.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentDiscovery.tsx new file mode 100644 index 0000000000..c8fdb4d80e --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentDiscovery.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from 'react'; +import { Select, Switch } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { externalSourcesAPI, type ExternalIntegrationPolicyMutation, type ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import type { EcosystemProductRuntime } from './ecosystemCompatibilityModel'; + +interface Props { + runtime: EcosystemProductRuntime; + snapshot: ExternalSourceCatalogSnapshot | null; + onSnapshotChange: (snapshot: ExternalSourceCatalogSnapshot) => void; +} + +export default function ExternalAgentDiscovery({ runtime, snapshot, onSnapshotChange }: Props) { + const { t } = useI18n('scenes/ecosystem-compatibility'); + const { workspacePath } = useCurrentWorkspace(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const alive = useRef(false); + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + const policy = snapshot?.integrationPolicy; + const mode = policy?.effective.ecosystems[runtime.spec.ecosystemId]?.mode ?? 'disabled'; + const canChange = policy?.status === 'compatible' && snapshot?.hostCapabilities.canMutatePolicy + && typeof snapshot.preferenceRevision === 'number'; + + async function change(change: ExternalIntegrationPolicyMutation['change']) { + if (!canChange || !snapshot || busy) return; + setBusy(true); + setError(false); + try { + const next = await externalSourcesAPI.updateIntegrationPolicy(workspacePath || undefined, { + expectedPreferenceRevision: snapshot.preferenceRevision!, + scope: workspacePath ? 'workspace' : 'user', + change, + }); + if (alive.current) onSnapshotChange(next); + } catch { + if (!alive.current) return; + setError(true); + try { + const current = await externalSourcesAPI.getSnapshot(workspacePath || undefined, false); + if (alive.current) onSnapshotChange(current); + } catch { /* Keep the last visible state; the failure remains explicit. */ } + } finally { if (alive.current) setBusy(false); } + } + + return
+

{t('sourceSettings.title', { name: runtime.spec.name })}

{t('sourceSettings.description')}

+
+ +

{t(workspacePath ? 'sourceSettings.projectScope' : 'sourceSettings.userScope')}

+