diff --git a/docs/architecture/extensions/capability-runtime-integration-design.md b/docs/architecture/extensions/capability-runtime-integration-design.md index 0c2bc51e47..193ea69e5a 100644 --- a/docs/architecture/extensions/capability-runtime-integration-design.md +++ b/docs/architecture/extensions/capability-runtime-integration-design.md @@ -134,6 +134,12 @@ fresh external invocation 在 admission 前绑定逻辑名到 runtime generation owner 按原规则优先执行。当前外部 Subagent 不支持 session follow-up,结果与管理 surface 必须明确标为 single-run, 不能用持久化 session 绕过重新审批或 generation 解析。 +当前外部来源控制切片进一步落实了内部宿主共享边界:Command、Tool、Subagent、MCP 的 payload、审批与冲突仍归各自 +owner,`ExternalSourceControlPlane` 只收敛 bounded discovery、provider 隔离和 generation fencing; +`ExternalSourceControlSnapshotV1` 则只投影 discovery/desired/review/runtime/support、Host 能力、诊断和恢复动作。 +Desktop、TUI 和 Peer 发送同一闭合控制动作,Server 只返回相同 DTO 的 read-only 投影。该 DTO 是当前产品宿主契约, +不是公共插件 SDK;没有独立仓库外消费方和版本策略前,不向 Host Adapter facade 扩张。 + 对外能力门面不等于 Agent Runtime SDK 的全部接口。SDK 可以包含构建与运行 Agent 所需的底层能力;宿主 adapter 只消费当前场景需要的最小子集。外部产品只需要调用一个 BitFun workflow 时,不应被迫嵌入完整 Agent Runtime。 @@ -329,6 +335,10 @@ Host Adapter 必须把宿主 `AbortSignal`、turn interruption 或 session stop - outcome、类型化错误、是否可重试、是否可能已产生副作用。 - payload 或 artifact reference,以及隐私/脱敏分类。 +外部来源控制错误已经统一提供 `code/stage/retryable/correlation/causation/recoveryActions`,供 GUI、TUI、 +Peer 和 Server 做一致映射;用户文案仍归各宿主,不能解析 `detail` 控制流程。当前实现只使用有界结构化日志,不据此 +宣称公共事件或完整产品分析打点已经冻结;后续指标应复用 operation/generation/stage 身份叠加 observer,不能另建状态 owner。 + 投递按 at-least-once 和可去重设计;不承诺跨进程、网络和第三方宿主 exactly-once。Observer 失败只影响观测,不 回滚业务结果。外部宿主事件转换必须记录 loss/degradation,例如“宿主没有 queue-wait 事件”或“只能观察 PostToolUse,无法观察 admission”。 @@ -369,7 +379,7 @@ adapter 一律保持 `experimental/unsupported`,不能据本表标记产品可 |---|---|---|---|---|---| | Claude Code | Plugin、Skill、Agent、Hook、MCP | `degraded`:可在生命周期注入上下文、观察压缩,不能替换历史或 Compactor owner | `translated`:可定义 Agent/Subagent 和 Hook,不能替换 Agent Loop 或全局调度 | `translated`:可增加工具并按 Hook 规则修改/拒绝;多个匹配 Hook 并行,最终权限仍属宿主 | 官方滚动文档核对;完整 Provider 替换 `unsupported`,父子权限、Hook 并发和 transcript/文件系统 fork 差异需冻结样例。[Hooks](https://code.claude.com/docs/en/hooks) | | Codex | Plugin、Skill、Hook、MCP | `degraded`:`SessionStart`/`UserPromptSubmit` 可增加上下文;Compact Hook 只能观察或阻止当前压缩,不能替换历史或 Compactor owner | `translated`:支持 Subagent 和生命周期 Hook,不能通过普通插件替换调度内核 | `degraded`:MCP 可增加工具;Hook 对 Bash、`apply_patch` 和 MCP 提供有限拦截,不是全部内置 Tool 的 enforcement boundary;多个匹配命令 Hook 并发 | 官方滚动文档核对;现有产品扩展以有限生命周期拦截为上限,完整 Provider/权限替换 `unsupported`。[Hooks](https://learn.chatgpt.com/docs/hooks) | -| OpenCode | 配置、Agent、Skill、Plugin、Custom Tool、MCP | `degraded`:Plugin 可变换消息/参数,实验 Hook 可影响压缩 Prompt,不能替换会话内核 | `translated`:Primary/Subagent/Task/子会话可组合,插件不能替换 scheduler | `translated`:Hook 顺序执行;同名插件 Tool 可覆盖内置 Tool,是局部正式替换点 | 具体冻结版本、提交和样例见 [OpenCode 专项文档](opencode-extension-compatibility.md#1-基线与判断方法);风险是实验 Hook 漂移、加载顺序、Bun/npm 供应链和任意脚本副作用。[Plugins](https://opencode.ai/docs/plugins/) | +| OpenCode | 配置、Agent、Skill、Plugin、Custom Tool、MCP | `degraded`:Plugin 可变换消息/参数,实验 Hook 可影响压缩 Prompt,不能替换会话内核 | `translated`:Primary/Subagent/Task/子会话可组合,插件不能替换 scheduler | `translated`:Hook 顺序执行;同名插件 Tool 可覆盖内置 Tool,是局部正式替换点 | 具体冻结版本、提交和样例见 [OpenCode 专项文档](opencode-extension-compatibility.md#1-基线与判断方法);风险是实验 Hook 漂移、加载顺序、Bun/Node/npm 供应链和任意脚本副作用。[Plugins](https://opencode.ai/docs/plugins/) | | Trae | Rules、Skills、Memory、Custom Agent、MCP、Hooks | `degraded`:可贡献上下文,尚无公开稳定的压缩/Context Provider 契约 | `translated` 候选:Custom Agent 可独立或作为 Subagent;调度和任务状态仍由宿主持有 | `experimental`:MCP 可调用;2026-06 新增 Hooks,但公开事件、顺序和错误契约不足 | 官方滚动 Changelog 核对,尚无冻结版本样例;近期只评估 Rules/Skill/MCP/Custom Agent,Hook 深适配及状态/压缩/调度替换保持 `unsupported`。[Changelog](https://www.trae.ai/changelog) | ### 9.2 使用 SDK/Server 组装新的 BitFun 宿主 @@ -413,11 +423,11 @@ adapter 一律保持 `experimental/unsupported`,不能据本表标记产品可 | A1 内部装配 | 已有 owner 和第二个真实实现 | 只为一个能力增加一个 Slot,证明不可变 Resolution Generation、状态和 fallback | 同时拆 Memory/Context/Workflow/Subagent/Scheduler | 两个真实 Provider 可替换,入口行为等价,只有一个状态 owner。 | | B1 能力导出 | 具名试点消费者、具体用例、验收 owner、冻结宿主版本 | 一个窄用例通过 MCP/Skill/sidecar 或最小 adapter 接入一个宿主,并完成安装到卸载闭环 | 完整 SDK、全部宿主插件、通用 Hook ABI | 仓库外消费者真实使用;注册、启停、调用、权限、取消、事件、降级、卸载和恢复均有端到端证据。 | | B2 生命周期导出 | B1 的真实用例被一个生命周期缺口阻塞 | 只增加该用例需要的一个 Host Hook/Plugin adapter | 全事件镜像、多个宿主并行铺开 | 宿主顺序、失败、并发、权限合并和卸载残留有冻结样例;适配损失可查询。 | -| C1 外部能力导入 | 既有 OpenCode 专项前置条件 | 完成一个真实 standalone Tool,再按阻塞样例增加 package/Hook | 全量 config、TUI renderer、Server、Remote plugin | 一个外部能力完成发现、确认、执行、取消、故障、更新和 UX 闭环。 | +| C1 外部能力导入 | 既有 OpenCode 专项前置条件 | 已完成 Prompt Command、standalone Tool、Subagent、MCP 的真实纵向切片与跨产品宿主控制面;下一步只按阻塞样例增加 package/Hook | 全量 config、TUI renderer、OpenCode Server、SSH Remote plugin | 一个外部能力完成发现、确认、执行、取消、故障、更新和 UX 闭环,且 Desktop/TUI/Peer 不出现第二状态 owner。 | | D1 SDK 发布 | 已有非 `bitfun-core` 嵌入方真实使用 B1 或现有 Runtime 能力 | 为已被消费的最小 API 冻结版本和迁移策略 | 发布 `product-full`、manager、Host ABI 或空 profile | 最小依赖、兼容测试、示例、升级路径和独立版本边界同时成立。 | | X1 单项扩大 | 对应轨道前一切片稳定且有新真实需求 | 每次只增加一个宿主或一个能力类别 | 追求矩阵全绿 | 新切片通过自己的兼容、产品、生命周期和故障验收。 | -近期唯一已承诺实现方向是 C1 的 OpenCode Prompt Command / standalone Tool 纵向路线。A1、B1 和 D1 在各自 +近期唯一已承诺实现方向仍是 C1 的 OpenCode 纵向路线;控制面收敛不自动承诺 package plugin 或 Hook。A1、B1 和 D1 在各自 启动门槛满足前只允许收集具名需求和冻结证据,不创建公共接口或并行铺开;Trae 在公开稳定 Hook/SDK 契约不足时 继续保持研究状态。 diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index a3806fd272..5ca5607866 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -7,9 +7,11 @@ SDK 或 Server 输出到外部宿主,以及内部 Provider Slot、状态、事 [`capability-runtime-integration-design.md`](capability-runtime-integration-design.md);两条方向共用适用的身份事实和能力 owner, 但不共用一个大一统 adapter 或状态模型。 -本文同时记录当前可用纵向切片与目标架构。当前 BitFun 已具备通用外部来源目录和生命周期协调器,并通过 -OpenCode Prompt Command 适配器接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突, -交互式 TUI(ChatMode)可列出并执行 prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 +本文同时记录当前可用纵向切片与目标架构。当前 BitFun 已具备通用外部来源目录、四条能力专属发现通道和单一 +`ExternalSourceControlPlane` 生命周期 owner;`contracts/product-domains` 提供版本化控制事实、闭合动作与错误语义, +Desktop、交互式 TUI、Peer Host 和只读 Server 只做宿主投影,不再各自派生另一套状态机。OpenCode Prompt Command +适配器已接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突,交互式 TUI(ChatMode)可列出并执行 +prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与交互式 TUI(ChatMode)使用同一决策状态。第三条纵向 切片已把 OpenCode 全局/项目 Subagent 的安全子集通过独立 provider 契约接入现有 Subagent owner:首次启用与 同名冲突使用非阻塞决策,fresh 调用固定不可变 generation,更新和撤下不会静默切换到同名实现。第四条纵向切片 @@ -99,11 +101,18 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器、prim | 内容 | 配置、Rules、Agents、Skills、Commands、MCP、插件、工具等类别与数量。 | | 状态 | 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用或不可用。 | | 变化 | 最近成功读取时间、候选摘要、已应用摘要、权限或能力变化。 | -| 操作 | 查看详情、应用/启用、按项目或执行域抑制/恢复兼容来源、停用执行 target、撤销显式导入、重新加载、低风险/代码更新改为先询问、显式导入为 BitFun 配置。 | +| 操作 | 查看详情、应用/启用、按项目或执行域抑制/恢复兼容来源、进入/退出 Safe Mode、停用执行 target、撤销显式导入、重新加载、低风险/代码更新改为先询问、显式导入为 BitFun 配置。 | 默认视图只展示用户需要处理的事项和聚合结果;文件级诊断、字段来源、依赖和执行身份进入详情。来源删除或更新 失败不要求用户阅读日志才能理解结果。 +Safe Mode 是执行域/工作区实例内的易失控制状态,不写入来源偏好,也不把来源伪装成 `disabled`。进入后继续发现和 +展示 Command、Tool、Subagent 与 MCP,但立即撤下外部 Tool、Subagent 和 MCP 的新调用路由;Prompt Command 作为 +静态模板继续可见。退出后基于当前来源 generation 重新协调,不能恢复已删除、已撤销或已过期审批的旧路由。 +GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;只读 Server 通过 +`hostCapabilities` 明确拒绝变更。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` +并重新读取,不能用界面本地状态覆盖并发进程的新决定。 + ### 3.3 兼容来源与显式导入 | 方式 | 适用场景 | 来源变化后 | 写入边界 | @@ -207,21 +216,24 @@ flowchart LR Sources["用户全局 / 项目 / 工作区外部来源"] Adapters["同级生态适配器:OpenCode / Codex / Claude Code"] Ports["能力专属 provider 契约"] - Catalog["外部来源目录与只读状态"] + Catalog["外部来源目录"] Watch["文件观察与外部变化事实"] - Coordinator["共享生命周期协调器:候选、差异与原子替换"] + Coordinator["ExternalSourceControlPlane:四条 typed discovery lanes"] + Projection["v1 控制事实 + 公共 catalog"] Policy["激活策略与各能力 owner"] Config["Runtime Configuration Service"] Host["Plugin Runtime Host"] Owners["Command / Skill / MCP / Tool / Config / Subagent 等归属模块"] - Surface["Desktop / CLI / Web / SDK"] + Surface["Desktop / TUI / Peer / read-only Server"] Sources --> Adapters Adapters --> Ports Watch --> Coordinator Coordinator --> Ports Ports --> Catalog - Catalog --> Surface + Catalog --> Projection + Coordinator --> Projection + Projection --> Surface Coordinator --> Policy Policy --> Config Policy --> Host @@ -237,7 +249,8 @@ flowchart LR | 能力专属 provider 契约 | 用来源限定身份交付 Command、Tool、Subagent 等类型化定义与调用/展开结果 | 携带任意 payload 的通用资产对象,或让一种能力的新增字段污染其他能力。 | | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和严格同卷原子替换等通用文件能力;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | -| 共享生命周期协调器 | 调用已注册 provider、生成不可变候选、按 provider 原子替换、保留隔离诊断,并请求能力 owner 切换 | 按生态 ID 分支业务行为、解析生态文件、直接提交配置、工具、权限或界面状态。 | +| 共享生命周期协调器 | 由单一 `ExternalSourceControlPlane` 持有 Command、Tool、Subagent、MCP 四个 typed coordinator 和四条 discovery lane;按 provider 复用唯一 in-flight 请求、隔离超时和失败、以 generation fencing 拒绝迟到结果,再请求能力 owner 切换 | 按生态 ID 分支业务行为、把四类 payload 合并为通用资产、解析生态文件、直接提交配置、工具、权限或界面状态。 | +| 版本化控制投影 | 以正交的 discovery/desired/review/runtime/support 事实推导一级状态;向宿主提供同 generation 的 control/catalog、`hostCapabilities`、恢复动作和闭合通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行 payload、替代能力专属审批和冲突 DTO,或让 GUI/TUI 自行推导生命周期。 | | 产品展示投影 | 按作用域、工作区或用户目录关系统一生成安全来源位置,清理可见诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | | 冲突解析 | 对独立 provider 或产品本地可执行能力的同名候选建立版本敏感指纹;未选择时不激活,选择后只在指纹不变时复用。现有 Skill 固定根顺序由 Skill owner 独立维护 | 用 adapter 优先级静默覆盖另一生态或本地可执行能力,或把选择写回外部文件。 | | 激活策略与各能力 owner | 根据风险、用户选择、组织上限和执行域决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | @@ -254,9 +267,17 @@ Assembly 知道当前构建注册了哪些具体 adapter;产品入口、目录 或 Claude Code 的私有类型。新增生态通过同级 adapter 与现有能力契约接入,不能修改另一个生态 adapter。 provider discovery 必须是可独立调度的 request/result,不在协调器锁内串行扫描。产品组装为每个 provider 设定期限, -超时后只沿用该 provider 的上一有效结果;健康兄弟 provider 继续更新。同步文件适配器超时后底层阻塞任务未必可取消, -因此同一 provider 同时最多保留一个 in-flight discovery,后续刷新复用它,完成后再提交结果,不能无限堆积线程。 -未来网络 provider 还应实现协作式 deadline/cancel,但不改变目录、冲突或产品入口契约。 +超时后只沿用该 provider 的上一有效结果;健康兄弟 provider 继续更新。同一 provider 同时最多保留一个 in-flight +discovery;期间到达的新刷新只合并最新 request,并把旧完成标记为不可发布,随后立即执行新 generation。同步文件适配器 +超时后底层阻塞任务未必可取消,因此进程级 discovery budget 固定为 8,后台等待最多 30 秒;超限后报告隔离状态, +但同一 provider 保留 generation tombstone 直到真实 worker 退出,期间只合并最新 request、不得重复占用线程或 permit。 +未来网络 provider 仍应实现协作式 deadline/cancel, +但不改变目录、冲突或产品入口契约。 + +控制请求的通用动作固定为 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`。能力专属的审批、冲突选择和执行参数继续由 +各 owner 的类型化契约承担,不能为了“一个 API”塞入任意 payload。错误以 `code + stage + retryable + +correlationId/causationId + recoveryActions` 表达;`detail` 只用于有界诊断,界面和远端协议不得解析文本 +决定控制流。日志只记录动作、阶段、关联 ID、错误类别和脱敏对象身份;产品打点可在同一结果上叠加,但不得反向改变状态。 来源降级必须区分粒度:整个配置/目录状态未知时回退对应来源;能确定身份的单个 Command 读取或解析失败时只回退该 Command;明确缺失且未被标记失败的 Command 是稳定删除。产品调用在刷新后还要校验先前投影的候选 ID 与内容版本, @@ -317,7 +338,8 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 3. 内置、MCP 与外部同名 Tool 使用包含候选身份和内容版本的指纹;选择前保留已有本地实现,不按注册顺序静默覆盖。 候选集合由已识别定义而非成功加载结果计算;候选更新、暂不可用或删除后重新选择,已选外部实现失效时保持 unavailable,不静默回退同名本地实现。其他无关 Tool 和生态不受影响。 -4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;交互式 TUI(ChatMode)状态栏只做非阻塞提示,通用 `/tools` +4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;交互式 TUI(ChatMode)状态栏只做非阻塞提示,`/extensions` + 负责共享状态、刷新和 Safe Mode,通用 `/tools` 入口在“外部 AI 应用”分组中以编号映射稳定 key 完成启用、保持停用、冲突选择和刷新;Agent 相关能力统一复用 `/agents`,在同一列表中以文字区分主 Agent、Subagent 与外部 AI 应用,不另建 `/subagents` 或 `external-*` 命令。 普通聊天输入和无关会话不等待用户处理;活动 turn 期间入口仍可查看和管理,但主 Agent 切换单独禁用并说明原因, @@ -331,7 +353,10 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 不形成循环重启。某个全局、项目、legacy 或显式目录不可读时只降级该目录,其他健康目录继续发现和运行。 6. `.ts`、依赖型 `.js`、`metadata`/`ask`、附件、package plugin、Hook、TUI plugin renderer 和 Remote worker 明确 延后;本机 Node 进程不是 OS 沙箱,VM realm 与隐藏响应令牌只保护协议稳健性。脚本继承当前用户的文件、网络、 - 环境与进程权限;本阶段没有 Job Object/process group,脚本直接启动的后代进程和系统资源风险必须持续显示。 + 环境与进程权限。脚本 worker 与 local stdio MCP 通过同一进程树边界启动:Unix 使用独立 process group 并在宽限后 + 升级终止,Windows 在恢复子进程前先加入 kill-on-close Job Object;附着失败时 fail closed。该机制回收已纳管后代; + Unix 主动创建新 session/process group 的逃逸进程不在该边界内。它也不限制文件/网络权限、CPU/内存消耗, + 因此仍须明确显示残余风险,不能称为沙箱。 第三阶段以 OpenCode MCP 配置来源验证声明式外部连接的接纳与生命周期: diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 2ad95c1d3c..902c71097c 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -19,17 +19,18 @@ ## 1. 基线与判断方法 -本次清单刷新于 2026-07-17: +本次清单刷新于 2026-07-21: -- 最新稳定版为 [`v1.18.3`](https://github.com/anomalyco/opencode/releases/tag/v1.18.3),提交为 [`127bdb30784d508cc556c71a0f32b508a3061517`](https://github.com/anomalyco/opencode/commit/127bdb30784d508cc556c71a0f32b508a3061517)。 -- 开发分支前瞻检查记录为提交 [`08fb47373509ba64b13441061314eeacf4264f51`](https://github.com/anomalyco/opencode/commit/08fb47373509ba64b13441061314eeacf4264f51)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 +- 最新稳定版为 [`v1.18.4`](https://github.com/anomalyco/opencode/releases/tag/v1.18.4),提交为 [`49c69c5ed3ccf706b61b3febb43c8aaff7f8325e`](https://github.com/anomalyco/opencode/commit/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e)。 +- 开发分支前瞻检查记录为提交 [`849c2598abc7d2b40261e74b5826bc74ffc78308`](https://github.com/anomalyco/opencode/commit/849c2598abc7d2b40261e74b5826bc74ffc78308)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 +- `v2` 前瞻分支检查记录为提交 [`44b6938b2ad2895bdd89d5d5cf753fb01ca8e3dd`](https://github.com/anomalyco/opencode/commit/44b6938b2ad2895bdd89d5d5cf753fb01ca8e3dd):仓库仍以 Bun 作为 package manager、开发和默认编译路径,同时提供 Node 26 SEA 并行构建与 Node 启动器。它不是“已经完全切换 Node”的稳定承诺。 - 配置、插件、工具、Agent、Skill、Command、Rule、MCP、LSP、Formatter、Theme、Keybind、开发工具包、Server 和 ACP 以 [OpenCode 官方文档](https://opencode.ai/docs/) 为准。 -- 稳定服务插件接口以 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/index.ts) 为准; -- custom tool 接口以 [`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/tool.ts) 为准; -- 终端插件接口以 [`packages/plugin/src/tui.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/tui.ts) 为准; -- 终端插件行为说明以 [`tui-plugins.md`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/opencode/specs/tui-plugins.md) 为准。 +- 稳定服务插件接口以 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/index.ts) 为准; +- custom tool 接口以 [`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tool.ts) 为准; +- 终端插件接口以 [`packages/plugin/src/tui.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tui.ts) 为准; +- 终端插件行为说明以 [`tui-plugins.md`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/opencode/specs/tui-plugins.md) 为准。 -稳定兼容只冻结 `v1.18.3` 的公开文档、接口源码和样例;开发提交仅用于发现未来差异,不进入当前承诺。升级时必须 +稳定兼容只冻结 `v1.18.4` 的公开文档、接口源码和样例;开发及 `v2` 提交仅用于发现未来差异,不进入当前承诺。升级时必须 重新比较实际消费的文件和行为,不能沿用本次结论。 ### 1.1 差异类型 @@ -66,11 +67,14 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 非阻塞摘要;可执行内容在首次启用或能力扩大时等待来源/target 级确认,但不阻塞项目和无关会话。 - 设置中的统一外部来源视图负责解释全局/项目作用域、当前支持范围、待处理项和变更结果;显式导入只是把 非执行内容转为 BitFun 原生配置的可选快照,不是 OpenCode 项目可用或插件执行的前置条件。 +- Desktop、TUI、Peer Host 与只读 Server 使用同一版本化控制 DTO。控制面只共享正交生命周期、Host 能力、恢复动作、 + `Refresh/SetSourceEnabled/SetSafeMode`,不携带 OpenCode 私有 payload;审批和冲突仍归 Tool/Subagent/MCP 等能力 owner。 - 第一条执行闭环已覆盖官方复数目录和源码验证过的单数目录中的受支持单文件 `.js` standalone tool;`.ts`、模块依赖、 package plugin、完整配置、Hook 和 TUI target 仍只识别或延后。当前范围和完整兼容目标必须分别表达,不能用 一个 JS fixture 宣称 OpenCode runtime 完整兼容。 -- PR2 使用本机 Node.js 验证受限 JS 子集,并在 Desktop 与交互式 TUI(ChatMode)显示运行时和无 OS 沙箱边界;固定 Bun、完整 - TypeScript/Zod 与包依赖仍是后续兼容目标。HarmonyOS PC 原生 CLI/TUI 必须按 +- 当前 standalone Tool 使用本机 Node.js 验证受限 JS 子集,并在 Desktop 与交互式 TUI(ChatMode)显示运行时和无 OS 沙箱边界; + 脚本 worker 与 local stdio MCP 已共享跨平台进程树回收。OpenCode v2 的 Node SEA 前瞻证明 Node 是可行执行路线,但其 + Bun 编译路径仍存在;BitFun 后续 TypeScript/Zod、`$` 与包依赖必须按冻结样例选择运行时 adapter,不能提前把 Bun 或 Node 固化进 Host ABI。HarmonyOS PC 原生 CLI/TUI 必须按 [平台专题](../platform-portability-design.md)独立取证,不包含 HarmonyOS 手机 Remote App。 - 扩展调用必须有期限、取消、有界队列、大小检查和可观察的崩溃降级;更细的权限、沙箱和组织策略沿用现有控制点并延期 单独设计,不在首条闭环扩大接口。 @@ -125,17 +129,17 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | OpenCode 扩展项 | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | 细节 | |---|---|---|---|---|---|---| | `.opencode/tools/*.js` | 补基础能力 | 受支持单文件子集已接入 Tool Runtime | 可完整适配 | OC-R2 | 当前 Node worker 支持基础 schema、默认值、字符串结果、取消/超时/撤下;完整 Zod、模块依赖、`metadata`/`ask` 和附件结果继续走类型化进程通信扩展 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | -| `.opencode/tools/*.ts` | 补基础能力 | 已识别,执行不支持 | 可完整适配 | OC-R2 | 当前静态显示不 import;后续由固定 Bun/TypeScript worker 保留真实 schema 与 execute,不在 Rust 猜测 TS 语义 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | +| `.opencode/tools/*.ts` | 补基础能力 | 已识别,执行不支持 | 可完整适配 | OC-R2 | 当前静态显示不 import;后续由冻结样例选择 Node 转译或 Bun/TypeScript worker,保留真实 schema 与 execute,不在 Rust 猜测 TS 语义 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | | 插件 `tool` map | 补基础能力 + 补扩展接口 | 未实现 | 可完整适配 | OC-R2 | 运行插件工厂,按同一双表示注册真实工具,并接到 Tool 归属模块 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | | 项目与用户目录插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 直接发现本地 JS/TS 模块,不要求 BitFun 专用清单;来源/target 确认后由隔离候选加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | -| 配置中的软件包插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 来源/target 确认后用 npm 配置、Arborist、package-lock 和 `ignoreScripts: true` 准备依赖,再由固定版本 Bun 加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | +| 配置中的软件包插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 来源/target 确认后用 npm 配置、Arborist、package-lock 和 `ignoreScripts: true` 准备依赖,再由与冻结插件样例匹配的 Node/Bun adapter 加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | | 全局插件加载 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 自动发现全局配置和 ConfigPaths 全局目录,并按完整来源图生成 `plugin_origins`;首次可执行启用按来源/target 确认,决定只提示一次且可按项目覆盖 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | | `package.json`、入口与依赖 | 补基础能力 | 未实现 | 可主要适配 | OC-R2 | 复现 server target、入口回退、`engines.opencode`、npm 配置和锁文件;原生模块失败只影响对应插件 | [来源与执行版本](opencode-plugin-runtime-adapter-design.md#4-来源与执行版本) | | 内置/MCP/外部同名工具;后续 pure/重复插件顺序 | 融合现有能力 | standalone Tool 显式选择已实现 | 可完整适配 | OC-R2 | 当前按候选身份与内容版本记忆选择且不静默覆盖;package plugin 阶段再复现 internal-first、pure、来源顺序和去重 | [注册与覆盖](opencode-plugin-runtime-adapter-design.md#53-注册与覆盖) | | `project` / `directory` / `worktree` | 直接桥接 | standalone Tool 已传 `directory/worktree/sessionID`;完整 `project` 未实现 | 可完整适配 | OC-R2 | 当前 `directory` 为打开的 workspace、`worktree` 为 Git 根并传递真实 session;完整插件 `project` 和 Remote 在 OC-R5 前保持 `unsupported` | [兼容门面](opencode-plugin-runtime-adapter-design.md#7-opencode-兼容门面) | | `client` | 补扩展接口 | 未实现 | 可主要适配 | OC-R2 | 提供版本化插件客户端门面,按方法转发到现有 BitFun 归属模块 | [兼容门面](opencode-plugin-runtime-adapter-design.md#7-opencode-兼容门面) | | `serverUrl` | 补扩展接口 | 未实现 | 可主要适配 | OC-R2 | 在 worker 执行域提供真实回环服务,只实现插件所需的版本化路由 | [兼容门面](opencode-plugin-runtime-adapter-design.md#7-opencode-兼容门面) | -| `$` 与脚本环境 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 固定 Bun worker 提供真实 `$`;受限模式只能依赖真实 OS/容器边界,无法落实时停用 target | [默认策略](opencode-plugin-runtime-adapter-design.md#3-默认策略与可调权限) | +| `$` 与脚本环境 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 只有需要 OpenCode/Bun `$` 的冻结样例才启用 Bun-compatible adapter;Node 路径不能伪造等价语义。受限模式依赖真实 OS/容器边界,无法落实时停用 target | [默认策略](opencode-plugin-runtime-adapter-design.md#3-默认策略与可调权限) | | 加载、停用、更新与崩溃恢复 | 补基础能力 | standalone Tool fail-closed 已实现 | 可主要适配 | OC-R2 | 已有来源限定 target、后台重载、删除撤下与 worker 终止;精确物化旧版本、健康旧进程保留和退避恢复仍待完整 Host | [生命周期](opencode-plugin-runtime-adapter-design.md#9-生命周期与能力暴露) | | 跨插件进程全局共享 | 明确降级 | 未实现 | 明确降级 | OC-R2 | 每 target 使用独立可终止进程;不承诺 `globalThis`、进程环境或模块单例的未文档化共享 | [故障域](opencode-plugin-runtime-adapter-design.md#81-故障域) | @@ -268,6 +272,10 @@ worker。授权在准备前、准备后 import 前、load 后注册前和每次 会立即撤下该 target 路由并标记 `load_failed`,不回退同名内置/MCP 实现,也不自动重放;下一次 Tool Catalog 暴露前 只消费一次恢复预算,仍失败则等待显式刷新或来源变化,不形成重启风暴。 +当前脚本 worker 与 local stdio MCP 统一通过 `services-integrations` 的进程树边界启动:Unix 以独立 process group +完成宽限终止和强制回收,Windows 在子进程恢复前附着 kill-on-close Job Object,附着失败则不运行。这解决了受管 +后代在取消/崩溃/Host 退出后的常规回收,但不限制文件、网络、CPU、内存或可逃逸行为,仍不构成安全沙箱。 + 交互式 TUI(ChatMode)更新订阅在活动期间持有工作区服务;Desktop/Agent 每次装配模型可见 Tool Catalog 时续期并在首次或空闲 回收后同步刷新。首次后台刷新与 catalog 装配共享同一个完成门闩:catalog 等待在途结果,失败后允许下一次装配重试。 没有订阅或目录装配活动的工作区在 5 分钟后撤下路由并回收 worker,避免依次打开多个项目后永久累积 Node 进程; @@ -316,7 +324,7 @@ worker。授权在准备前、准备后 import 前、load 后注册前和每次 | 未文档化内部接口 | 不承诺 | 没有稳定版本和契约 | 返回稳定不支持并进入版本前瞻报告。 | | `experimental_workspace.register` | 暂不承诺 | 接口未稳定且会改变工作区与远程连接归属 | 继续使用 BitFun Workspace/Remote 归属模块,稳定后重评。 | | 受限策略下拦截任意脚本副作用 | 只能部分控制 | 插件可以直接调用脚本运行时,绕过细粒度能力代理 | 来源激活后默认兼容策略放开;用户收紧时明确列出被禁用或无法拦截的能力。 | -| 无硬资源限制平台上的系统资源耗尽 | 不能保证完全隔离 | 独立进程可终止死循环,但未必能阻止内存、CPU 或子进程风暴拖慢整机 | 使用进程树回收与平台可执行的 Job Object、cgroup/rlimit;缺少硬限制时显示残余风险。 | +| 无硬资源限制平台上的系统资源耗尽 | 不能保证完全隔离 | 已有进程树可回收受管后代,但仍不能阻止内存、CPU、网络或逃逸进程拖慢整机 | 在真实需求下增加 cgroup/rlimit/容器等平台额度;缺少硬限制时显示残余风险。 | 这些限制已经作为当前架构决策:项目状态只能表述为“兼容矩阵已审计、已实现项按证据列示”,不能表述为“稳定 扩展面已完整实现”或“所有插件完整兼容”。只有真实需求和新证据可以重新开启延期项。 diff --git a/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md b/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md index 2dee3b55ac..7487d99e7d 100644 --- a/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md +++ b/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md @@ -8,24 +8,29 @@ 外部来源的产品提示、风险分级和用户选择见 [`external-ai-work-sources-design.md`](external-ai-work-sources-design.md)。 -冻结接口以 `v1.18.3` 稳定提交的 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/index.ts)、 -[`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/tool.ts) +冻结接口以 `v1.18.4` 稳定提交的 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/index.ts)、 +[`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tool.ts) 和实际插件 loader/npm 服务为准;[插件文档](https://opencode.ai/docs/plugins/)用于行为说明。 本文同时区分当前纵向切片与完整目标。当前实现已支持用户/项目 standalone `.js` Tool 的静态发现、非阻塞审批、 Node worker `load/invoke/cancel/dispose`、现有 Tool Runtime 注册、同名冲突选择与更新/删除撤下;`.ts` 和依赖型 JS -只显示不支持。固定 Bun、完整 Zod/TypeScript、package plugin、OpenCode 兼容 Client 和稳定 Hook 仍是目标设计, +只显示不支持。完整 Zod/TypeScript、package plugin、OpenCode 兼容 Client 和稳定 Hook 仍是目标设计, 不能把 PR2 的 Node 子集写成完整运行时兼容。 +2026-07 的前瞻检查显示 OpenCode `v2` 仍以 Bun 作为 package manager、开发和默认编译路径,同时提供 Node 26 SEA +并行构建与 Node 启动器。该分支尚未形成稳定运行时承诺,因此 BitFun 保持 `ScriptToolRuntime`/worker protocol +运行时中立:当前 `.js` 子集使用系统 Node;只有冻结的 TypeScript、Zod、`$` 或 package plugin 样例证明需要时, +才增加 Node 转译或 Bun-compatible Runtime Adapter,不把任一物理运行时固化进 Host ABI。 + ## 1. 核心决策 BitFun 实现自己的插件 Runtime,不启动完整 OpenCode Runtime: - 每个外部插件 target 在 BitFun 管理的独立、可终止脚本进程中运行;服务 target 与 TUI target 也彼此隔离。 -- 首选执行后端是由 BitFun 固定版本并随产品交付或按需安装的 Bun,而不是自行重写 JavaScript 引擎或用 - Node 兼容层猜测 Bun 行为。Bun 负责 TypeScript、模块执行和 Bun Shell `$`; - 发布前必须完成对应平台的许可证、签名、更新和体积验证。 -- 依赖准备不使用 `bun install` 猜测 OpenCode 行为。`v1.18.3` 兼容模块使用 npm 配置、`@npmcli/arborist`、 +- 物理执行后端通过 Runtime Adapter 选择。当前 standalone `.js` 子集使用经实际 `--version` 探测的系统 Node; + TypeScript、模块解析、Zod refinement 和 `$` 必须按冻结样例选择 Node 转译或 Bun-compatible adapter,不能互相猜测语义。 + 若随产品交付运行时,发布前必须完成对应平台的许可证、签名、更新和体积验证。 +- 依赖准备不使用 `bun install` 猜测 OpenCode 行为。`v1.18.4` 兼容模块使用 npm 配置、`@npmcli/arborist`、 `package-lock.json` 和 `ignoreScripts: true`;后续 OpenCode 版本改变实现时随兼容版本更新。 - 执行进程提供 OpenCode 兼容上下文和插件接口;插件无需感知 BitFun Rust 内部类型。 - OpenCode Adapter 把插件调用、钩子变换和工具结果转换成类型化进程消息。 @@ -35,11 +40,11 @@ BitFun 实现自己的插件 Runtime,不启动完整 OpenCode Runtime: 前置条件,用户、产品和组织可以按需收紧。 “BitFun 自有 Runtime”表示 BitFun 自己实现来源发现、依赖准备、worker 监督、OpenCode API 适配、进程通信、 -Rust 能力转发和状态恢复;并不表示重写 Bun,也不表示在后台启动 OpenCode 的 Agent Runtime。插件工厂和钩子 -在 Bun worker 中运行,需要 BitFun 能力时通过兼容接口转发给 Rust 归属模块。 +Rust 能力转发和状态恢复;并不表示重写 JavaScript 引擎,也不表示在后台启动 OpenCode 的 Agent Runtime。插件工厂和钩子 +在所选 Runtime Adapter 的 worker 中运行,需要 BitFun 能力时通过兼容接口转发给 Rust 归属模块。 PR2 的 `ScriptToolRuntime` 是 provider-neutral 的窄端口,当前 Node 实现只用于受支持的单文件 JavaScript standalone Tool。 -它不把 Node 固化进 Host ABI,也不承担 OpenCode 格式、审批或冲突规则;后续 Bun 实现可以替换物理执行服务而 +它不把 Node 固化进 Host ABI,也不承担 OpenCode 格式、审批或冲突规则;后续 Node 转译或 Bun-compatible 实现可以替换物理执行服务而 无需修改 Tool owner、产品表面或其他生态 adapter。 “最终由归属模块提交”不是把 OpenCode 可写钩子降级成只读通知。默认策略允许合法钩子变换生效;归属模块只负责 @@ -58,7 +63,7 @@ flowchart LR Host["Rust Plugin Runtime Host"] Adapter["OpenCode Adapter"] Bridge["类型化进程通信"] - Worker["Bun 插件执行进程"] + Worker["Runtime Adapter 插件执行进程"] Facade["OpenCode Compatibility Facade"] Plugin["OpenCode 插件 / Tool"] Owners["Tool / Session / Config / Provider / Event 归属模块"] @@ -104,7 +109,7 @@ Runtime Product Assembly 只选择并构造已编译的 adapter/provider,再 | OpenCode Source Coordinator | 读取 Config 归属模块提供的快照,维护来源身份/顺序、监听、候选代次和切换决定,并消费来源激活结论 | 写 BitFun 配置、保存凭据、决定产品提示、安装依赖、管理 worker 或提交贡献 | | 外部来源目录与激活策略 | 展示来源/作用域/能力摘要,保存用户加载偏好,并结合各 owner 与组织上限给出自动应用、待确认或限制结论 | 解释 OpenCode 加载顺序、import module、管理 worker 或代替调用时权限判断 | | 依赖准备服务 | 按兼容版本管理 npm 配置、Arborist、package-lock、缓存和安装锁 | 执行插件代码或擅自启用安装脚本 | -| 脚本执行服务 | 唯一持有固定版本 Bun、每 target OS 进程树/句柄,负责物理健康探测和资源回收 | 理解 OpenCode Hook 语义或提交 BitFun 业务状态 | +| 脚本执行服务 | 唯一选择并持有 Node/Bun-compatible Runtime Adapter、每 target OS 进程树/句柄,负责物理健康探测和资源回收 | 理解 OpenCode Hook 语义、把物理运行时固化进 Host ABI 或提交 BitFun 业务状态 | | OpenCode worker 适配库 | 加载真实模块,提供插件工厂上下文、`client`、`$` 和 target API | 暴露 Rust 内部类型或自行修改 BitFun 存储 | | 进程协议适配器 | 把工具、Hook、Client 和生命周期调用转换成类型化消息 | 使用任意 JSON 和字符串事件替代所有接口 | | Plugin Runtime Host | 期限、取消、有界队列、逻辑 target 状态、进程失联事实消费、响应校验和诊断 | 持有 OS 进程句柄/进程树,或决定 OpenCode 加载顺序、权限结果和工具结果 | @@ -118,7 +123,7 @@ Runtime Product Assembly 只选择并构造已编译的 adapter/provider,再 ### 3.1 OpenCode 兼容策略(本地默认) -- 使用 BitFun 管理的固定版本 Bun 加载 JS/TS;依赖按冻结 OpenCode 版本的 npm/Arborist 语义准备。 +- 使用与冻结样例匹配的 Runtime Adapter 加载 JS/TS;当前 `.js` 子集使用系统 Node,依赖按冻结 OpenCode 版本的 npm/Arborist 语义准备。 - 自动发现项目/用户插件、standalone tools 和配置目录 `package.json`。 - OpenCode 配置列出的插件和标准插件/工具目录自动进入来源清单,但首次不得直接 import。系统按“来源限定身份 + target + 执行域 + 能力摘要”形成非阻塞待确认项;用户处理后,同一摘要下的依赖准备、worker 启动和贡献注册 @@ -153,7 +158,7 @@ Runtime Product Assembly 只选择并构造已编译的 adapter/provider,再 | 控制层 | 可执行机制 | 控制粒度 | |---|---|---| | 经 Compatibility Facade / Tool Runtime 的 BitFun 能力 | 在 Rust 归属模块检查来源、凭据、文件范围、工具覆盖、配置写入、模型请求修改和界面贡献 | 可按调用和贡献细分 | -| Bun 直接文件、网络、环境和子进程能力 | 兼容模式使用当前执行用户;受限模式只能依赖真实操作系统或容器边界 | 只能按执行环境粗粒度限制;边界无法落实时停用 target | +| 脚本运行时直接文件、网络、环境和子进程能力 | 兼容模式使用当前执行用户;受限模式只能依赖真实操作系统或容器边界 | 只能按执行环境粗粒度限制;边界无法落实时停用 target | 用户、产品或组织可以选择兼容/受限模式,并在 Host 代理层调整自己有权修改的细项。策略必须满足: @@ -189,7 +194,7 @@ OpenCode 项目和用户目录可以直接成为兼容来源。OpenCode Source C | 插件和工具入口 | 固定本次准备加载的模块。 | | 配置目录 `package.json`、npm 配置、`package-lock.json` 及内容摘要 | 判断何时需要重新准备依赖。 | | 软件包实际解析版本、完整性信息和安装目录摘要(能够取得时) | 避免只记录包名导致错误复用缓存。 | -| Bun 版本、平台和架构 | 解释平台差异并选择正确 worker。 | +| Runtime Adapter 类型、版本、平台和架构 | 解释平台差异并选择正确 worker。 | | 原生模块和运行时发现的动态依赖 | 展示风险并在变化后使相关缓存失效;安装脚本在当前兼容版本中禁用。 | | 内容摘要、能力/权限摘要和生成时间 | 缓存失效、确认复用、诊断和重载。 | @@ -228,8 +233,9 @@ BitFun 原生 `bitfun.plugin.json` 包继续使用现有来源校验;OpenCode 为 8 MiB,无法认证的 stdout 累计到 1 MiB 后终止 worker。`import.meta.url` 使用已校验的来源 URL。 - 当前进程不是 OS 沙箱。模块使用独立 VM realm 且协议响应令牌不经 stdin 暴露,只用于降低偶然协议破坏,不能阻止 已批准脚本获得当前用户的文件、网络、环境和进程能力,也不构成针对恶意模块的协议认证;获批脚本控制其 target - 内的执行语义。本阶段没有进程树/Job Object;硬取消只保证直接 worker 退出,其后代进程和已创建系统资源可能 - 存活,必须在 Desktop 与 CLI/TUI 确认详情持续披露。 + 内的执行语义。当前脚本 worker 与 local stdio MCP 已共享进程树边界:Unix 使用独立 process group,Windows 在恢复 + 子进程前加入 kill-on-close Job Object,附着失败时 fail closed。硬取消会回收已纳管后代,但不限制文件/网络、CPU、 + 内存或边界外逃逸进程,必须在 Desktop 与 CLI/TUI 确认详情持续披露。 - global、project、legacy 或 `OPENCODE_CONFIG_DIR` 任一目录读取失败时记录绑定该 source 的诊断并继续扫描其他目录, 不把单目录 I/O 故障升级成整个 OpenCode Tool provider 不可用。 - invoke 超时、空闲/调用中 worker 退出或 cancel 硬终止会通过带独立加载代次的 runtime health 事件立即撤下 target @@ -239,7 +245,7 @@ BitFun 原生 `bitfun.plugin.json` 包继续使用现有来源校验;OpenCode 完整目标在不改变 provider/owner 边界的前提下继续补齐: -- 固定 Bun 真实加载 JS/TS、依赖、default/具名导出;原始 Zod shape、refinement 和 `execute` 留在 worker, +- 与冻结样例匹配的 Node 转译或 Bun-compatible adapter 真实加载 JS/TS、依赖、default/具名导出;原始 Zod shape、refinement 和 `execute` 留在 worker, worker 用原始 Zod `safeParse` 校验调用,只把模型可见 JSON Schema 传给 Rust。 - `ToolContext` 补齐 message/agent 等真实执行上下文;`metadata({ title?, metadata? })` 与 `ask()` 通过反向类型化 调用更新流式元数据和请求权限。 @@ -358,7 +364,7 @@ OpenCode 类型。 每个外部插件 target 使用独立操作系统进程。单线程内的超时不能终止死循环,进程级隔离是回收失控执行的必要 条件,但独立进程本身不能保证系统级资源耗尽不影响其他插件: -- 插件初始化失败、崩溃或死循环不得直接终止 Host;期限到达后回收该 target 的完整进程树。 +- 插件初始化失败、崩溃或死循环不得直接终止 Host;期限到达后回收该 target 已纳管的进程组/树,并明确披露 Unix 主动脱离 process group 的残余风险。 - Windows 用 Job Object 管理进程树,Unix 至少使用独立 process group;内存、CPU 和子进程数在平台可执行时使用 Job Object、cgroup/rlimit 等硬预算。无法提供硬限制的平台必须显示残余风险,不能承诺内存或进程风暴完全不影响宿主。 - 初始化失败回滚该插件本次注册的工具、Hook 和状态,继续加载其他插件。 - 服务插件和终端插件分别启动、启停和恢复;一端失败不使另一端自动失效。 diff --git a/docs/architecture/extensions/plugin-runtime-host-design.md b/docs/architecture/extensions/plugin-runtime-host-design.md index ffc35f2144..39a69412e0 100644 --- a/docs/architecture/extensions/plugin-runtime-host-design.md +++ b/docs/architecture/extensions/plugin-runtime-host-design.md @@ -107,7 +107,7 @@ trait object 并注入 Host 的位置。Host 只围绕注入对象工作,不 | Config owner → OpenCode Source Coordinator | 规范化配置值、来源身份与顺序、配置代次;不含 worker 或动态导出 | Config owner 保存配置与来源解释 | | Source Coordinator → 依赖/脚本执行服务 | 来源限定身份、target、候选代次、入口、依赖与有效策略;返回经摘要校验的 prepared target 引用 | Coordinator 保存候选/激活代次;执行服务保存缓存、进程句柄和物化结果 | | Source Coordinator → Plugin Runtime Host | 已完成来源准入的 prepared target 引用、adapter binding、切换/停用请求;返回激活或失败状态 | Host 保存逻辑 target 状态、在途调用和贡献注册状态,不保存产品确认偏好 | -| Host → 依赖/脚本执行服务 | 经注入控制端口请求启动、类型化调用、取消、整树终止和物理健康探测;返回类型化结果/健康事实 | 执行服务保存 OS 句柄、进程树与资源事实;业务结果仍由归属模块提交 | +| Host → 依赖/脚本执行服务 | 经注入控制端口请求启动、类型化调用、取消、已纳管进程组/树终止和物理健康探测;返回类型化结果/健康事实 | 执行服务保存 OS 句柄、进程树与资源事实;业务结果仍由归属模块提交 | 新增接口前必须先指出真实调用方和最终状态归属。工具调用、钩子变换、OpenCode Client 代理和终端贡献的输入、 期限、错误与返回语义不同,应分别建立窄路径;不能为了减少接口数量把它们编码成字符串事件和任意 JSON。 @@ -223,7 +223,7 @@ Host 不解析 Prompt、Memory 或 Tool 内容自行打点,也不重复计算 ### 6.1 进程与队列 - 外部插件 target 使用独立操作系统进程,不与 Rust 主进程或其他插件共享不可终止的执行线程。 -- 脚本执行服务必须唯一持有完整进程树:Windows 使用 Job Object,Unix 至少使用独立 process group。Host 不持有 +- 脚本执行服务必须唯一持有已纳管进程树:Windows 使用 Job Object,Unix 至少使用独立 process group;Unix 主动脱离 process group 的后代属于明确残余风险。Host 不持有 平台句柄,只能经注入的执行控制端口请求取消、超时、停用和退出时终止整棵树,不能只回收直接子进程。 - 内存、CPU 和子进程数使用平台可执行的 Job Object、cgroup/rlimit 等预算;无法提供硬限制的平台必须显示残余风险,不能仅凭独立进程承诺资源耗尽不会影响其他插件或宿主。 - 初始化、工具、钩子、客户端代理和清理分别设置期限;清理超时不能阻止产品退出或终端恢复。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 84c1a5a7a5..3f92ae31d7 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -215,6 +215,9 @@ flowchart TB - 插件只进入扩展贡献接口,不直接写内核状态、工具结果、权限结果或审计事实。 - 插件运行时主机只负责类型化调用、期限、取消、有界队列、逻辑 target 状态、响应校验和故障状态; 物理进程健康、资源预算与进程树回收属于脚本执行服务。 +- 外部来源的 Command、Tool、Subagent、MCP 仍保留能力专属 DTO 和 owner,但它们的发现调度统一由 + `ExternalSourceControlPlane` 持有;跨 Desktop/TUI/Peer/Server 的控制事实只通过版本化 product-domain 投影共享, + 不复制生态 payload、界面状态机或远端专用 DTO。 - 每个生态适配层独立保留该生态的外部格式、来源顺序和调用语义,并映射到 BitFun 归属模块;它本身不成为新的 业务归属模块,也不能依赖或修改兄弟生态 adapter。通用目录、生命周期协调器和能力 owner 只依赖开放生态 ID、 来源限定身份与能力专属 provider 契约,不按 OpenCode、Codex 或 Claude Code 分支行为。 @@ -263,7 +266,8 @@ flowchart LR - 不启动完整 OpenCode Runtime,也不依赖用户安装 OpenCode CLI;BitFun 实现自己的监督、适配和 Rust 转发层。 当前 standalone Tool 子集通过受监督的 Node.js worker 执行且不安装依赖;未来只有冻结的 package plugin 样例证明 - 确有需要时,才单独裁决 Bun、依赖准备和版本兼容方案。 + 确有需要时,才单独裁决 Bun、依赖准备和版本兼容方案。OpenCode v2 当前同时维护 Bun 编译产物与 Node SEA 并行 + 产物,因此 BitFun 不把外部项目尚未收敛的运行时选择提升为 Host ABI 或核心架构约束。 - 用户全局和项目来源自动发现;低风险内容默认无感应用并显示可撤销摘要,可执行来源首次启用或能力扩大时等待 非阻塞确认。确认前不得 import module、启动 worker、读取凭据或产生直接脚本副作用。 - 激活后的本地 target 默认按 OpenCode 语义运行,允许当前用户通常拥有的文件、网络、进程和环境能力;用户、 @@ -327,13 +331,13 @@ flowchart LR | 产品形态 | 当前扩展能力 | 入口行为 | |---|---|---| -| Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;Beta“外部 AI 应用”设置已消费 OpenCode Command、standalone Tool、Subagent 和 MCP 的统一来源快照、审批、冲突选择与诊断;MCP 原生清单优先展示,外部候选保持独立来源、作用域和覆盖状态;Skills 场景显示已发现 Skill 的生态来源、用户/项目作用域与覆盖结果 | 当前四条可执行纵向切片由事实所在 Host 执行;本机 Desktop 使用本机 Host,Peer 控制界面代理 Peer Host,不在控制端回退发现或执行。Skill 仍使用独立 Registry,不据来源展示宣称已并入外部来源协调器。受管 package plugin 仍只有静态预览,不能据设置页接入宣称完整 OpenCode 插件运行时 | -| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并在通用 `/tools` 与 `/agents` 入口按文字分组消费与 Desktop 相同的审批、冲突和刷新状态;`/agents` 同时承载主 Agent 切换、Subagent 管理和“外部 AI 应用”,不再注册 `/subagents` 或 `external-*` 平行命令。活动 turn 期间仍可查看和管理,只有主 Agent 切换被禁用。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;非交互入口和 Remote 未接入时不得借本机 TUI 路径代执行。本地 Agent 与 Peer Host 路径选择 `DeliveryProfile::Cli` 并消费同一 Runtime Parts/SDK;主会话的恢复、转录、本地分支、用量报告生成/卡片持久化、模式更新与精确结算走 SDK,Peer Host 的基础会话创建/恢复/重命名/归档和 thread-goal 查询也走同一 SDK;远程分支明确不支持。TUI 模式切换异步持久化,期间保持输入和 resize 响应,只在成功后提交本地状态,失败保留原选择;用户可切换会话,退出有等待完成与再次强制退出两级路径。失效的主会话模式由 Core 恢复为可执行模式,TUI 显示迁移并阻止启动输入自动执行。本地工作区快照准备、会话文件清单、类型化统计和工作区文件回滚由 Desktop/Peer Host 共用的窄 owner port 调用现有 Core 实现;它不进入 SDK,不接受远程身份,Desktop 保留既有远程空结果,Peer Host 返回明确不支持错误,历史维护仍归宿主。账号同步、富历史及 Peer Host/ACP 其余持久化维护缺口仍由单一 Core 兼容门面转发。本切片不改变扩展执行边界 | +| Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;Beta“外部 AI 应用”设置消费版本化 control/catalog 同代快照,显示正交生命周期、审批、冲突、诊断和 Host 能力,并可切换易失 Safe Mode;MCP 原生清单优先展示,外部候选保持独立来源、作用域和覆盖状态;Skills 场景显示已发现 Skill 的生态来源、用户/项目作用域与覆盖结果 | 当前四条可执行纵向切片由事实所在 Host 执行;本机 Desktop 使用本机 Host,Peer 控制界面代理 Peer Host,不在控制端回退发现或执行。Safe Mode 只撤下外部 Tool/Subagent/MCP 新调用,不改写来源偏好、不停止发现也不取消在途调用;状态仅在当前 Host 进程/执行域有效,重启或切换 Host 后关闭,所有产品面必须明确提示。Skill 仍使用独立 Registry;受管 package plugin 仍只有静态预览 | +| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,`/extensions` 的 status、refresh、Safe Mode 与 source enable/disable 消费同一 v1 控制 DTO,`/tools` 与 `/agents` 继续承载能力专属查看和操作;命令结果复用现有异步事件循环,不新增调度器。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;Safe Mode 操作后重读权威 catalog 再更新命令路由。非交互入口和 SSH Remote 未接入时不得借本机 TUI 路径代执行;其余 Runtime Parts/SDK、会话和工作区 owner 边界保持不变 | | HarmonyOS PC 原生 CLI/TUI | 未来平台目标,当前未实现 | 目标、问题和风险见平台规约;具体适配另立专题,HAP、手机 Remote App 与远端代执行均不替代 | | HarmonyOS PC GUI | 完整 HarmonyOS PC 支持的另一目标形态,当前未实现 | 与 CLI/TUI 共享稳定能力和 Runtime 语义,但独立设计宿主、界面与发布验证;Web、Remote 或现有 Tauri Desktop 均不能替代 | | HarmonyOS 手机 Remote App | `src/apps/mobile/harmonyos` 是 phone-only ArkTS 远程入口,不持有本地 Rust Agent Runtime | 保持当前能力并按移动端专题独立演进;本轮不提前设计移动 Runtime/TUI/GUI,也不能据此宣称 HarmonyOS PC 本地能力 | | ACP | CLI 托管的服务端仍以 `bitfun-core/product-full` 作为兼容执行层 | 入口已选择 `DeliveryProfile::Acp` 并消费 Runtime Parts;组装层在入队前原子拒绝忙碌会话,不改变其他产品入口的排队行为;活动会话模型与模式写入走 SDK。`session/load` 先校验和建立临时 MCP,再恢复 Core,在历史回放成功后才发布活动状态;失败会卸载本次内存状态而不删除历史。同 ID 的重叠打开/关闭被明确拒绝。成功的 `session/close` 阻止新轮次、排空队列和后台子会话,再卸载临时 Core 状态并回收 MCP 与连接;失败保留会话所有权和历史,返回可重试阶段。持久化历史仍可重新加载。完整历史、模型/模式目录与配置读取仍留在现有 Core/ACP 归属,不据此宣称完整解耦 | -| Server / Remote | Server 可返回 Host 只读外部来源快照并对变更操作 fail closed;Peer Host 已代理外部来源快照、策略和既有四类操作;SSH Remote 工作区的外部来源发现与执行仍未实现 | 只读 Host 不伪装为可管理;控制端用 Host 身份与工作区共同隔离异步结果。Peer Host 只处理 Host 上的真实工作区,不在控制端替远端发现;SSH Remote 未接入时返回明确不支持且不回退本机来源 | +| Server / Remote | Server 可返回共享 v1 control/catalog 快照,`hostCapabilities` 明确为只读,并在解析 mutation payload 前 fail closed;Peer Host 代理同一读写控制动作与既有能力专属操作,连接旧 Peer 时读路径降级到 legacy catalog、来源启停降级到既有 mutation,Safe Mode 明确要求升级 Host;SSH Remote 工作区的外部来源发现与执行仍未实现 | 控制端用 Host 身份与工作区共同隔离异步结果。Peer Host 只处理 Host 上的真实工作区,不在控制端替远端发现;SSH Remote 未接入时返回明确不支持且不回退本机来源 | | Web / Mobile Web | 依赖现有后端入口,不持有插件执行单元 | 对应 profile 当前为空计划或未接入生产,不能据枚举值宣称独立产品能力 | | SDK | 仅有 preview 门面、空 profile 计划和测试替身 | 不牵引 `product-full`、具体服务管理器或插件 host ABI;未满足独立嵌入验证前不宣称可发布 | diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index 22e0a90e7e..4dabe513d2 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -160,7 +160,7 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo ### 4.2 产品与决策语义 - Desktop 在“外部 AI 应用”中显示来源、文件、工作目录、工具名和直接文件/网络/环境/进程能力,并明确提示当前 worker - 不是 OS 沙箱。交互式 TUI(ChatMode)使用同一快照:状态栏只做一次非阻塞提醒,通用 `/tools` 入口以“外部 AI 应用” + 不是 OS 沙箱。交互式 TUI(ChatMode)使用同一快照:`/extensions` 提供共享状态、刷新和 Safe Mode,状态栏只做一次非阻塞提醒,通用 `/tools` 入口以“外部 AI 应用” 分组提供静态预览以及 `enable`、`disable`、`choose`、`refresh` 操作;等待处理不阻塞输入或普通会话,不再注册平行的 `external-*` 命令。 - 首次启用键由“来源限定 target + 执行域 + runtime + 能力集合”组成。纯内容更新且能力集合不变时复用已批准 @@ -184,7 +184,8 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo 4. 每 target 持久 worker 的普通请求 30 秒无响应时终止;合作取消在 500 ms 后硬终止,输出限制 1 MiB、协议帧 限制 8 MiB;产品配置的更短 Tool 期限丢弃调用 future 时也会终止 worker,并在终止完成前保持串行许可。调用不 自动重放。Node 进程仍以当前用户权限运行;VM realm 和隐藏响应令牌不是安全 - 沙箱,本阶段也没有进程树/Job Object,直接创建的后代进程和系统资源不保证被回收,产品必须持续显示残余风险。 + 沙箱。当前脚本 worker 与 local stdio MCP 已共享 Unix process group / Windows kill-on-close Job Object 进程树回收; + 它不限制文件、网络、CPU、内存或逃逸行为,产品必须持续显示残余风险。 worker 丢失由带独立加载代次的 runtime health 事件立即撤下路由并显示失败;旧 worker 的迟到事件不能撤下同内容 新实例。下一次 catalog 暴露前仅恢复一次,失败后等待显式刷新或来源变化;不回退 同名内置/MCP 实现,也不形成自动重启循环。单个来源目录不可读只降级该目录,其他健康目录继续生效。 @@ -254,11 +255,12 @@ PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 本轮不新增扩展类型,只修复会误导用户或掩盖真实故障的两处问题,并校正文档事实: - `NodeScriptToolRuntime` 在共享外部 Tool 运行时首次初始化时解析 Node.js 可执行文件,并在当前进程内持有成功结果; - 只有缓存仍为空时,后续可用性检查或加载才重新执行当前进程可见范围内的 `which(node)`,不会替换已持有路径、worker + 只有缓存仍为空时,后续可用性检查或加载才重新执行当前进程可见范围内的 `which(node)`;可用性还会在 2 秒期限内 + 实际执行 `node --version`,并从探测和 worker 环境移除 `NODE_OPTIONS/NODE_PATH`。该过程不会替换已持有路径、worker 或活动调用。首次未找到 Node.js 后,Desktop 与 TUI 只提示安装或修复、刷新,以及可继续使用其他功能;当前宿主未记录 “安装后已经刷新但仍不可见”的独立证据,因此不主动建议重启,也不提供自动重启或“立即重启”动作,更不能中断活动 session。 - 刷新会重新发现来源并触发运行环境可用性检查,但不能承诺继承父进程后续收到的环境变量变化;运行时状态不得把 - 尚未实际启动的可执行文件写成“已验证”。 + 刷新会重新发现来源并触发运行环境可用性检查,但不能承诺继承父进程后续收到的环境变量变化;`node --version` + 探测通过前,运行时状态不得写成“可用”。 - Subagent owner 读取 BitFun 模型配置失败时必须 fail closed,并生成与“模型不存在”不同的通用诊断。GUI/TUI 提示用户先确认 BitFun 模型设置能够正常读取和保存,再刷新;日志只保留经过脱敏的失败阶段和错误类别。不得用 `AIConfig::default()` 把配置服务异常转换成候选模型不匹配,也不得把原始错误或绝对配置路径投影到普通快照。临时故障期间不得改写已持久化的 @@ -268,7 +270,7 @@ PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter - Desktop 与交互式 TUI 使用相同诊断 code 和激活阻断事实,各自负责适合宿主的文案;Remote 继续返回明确不支持, 不读取或执行本机同名来源。上述变化不修改 system prompt,也不新增跨界面渲染契约。 -明确延期到独立、由证据驱动的后续工作:OS/容器沙箱和进程树硬限制、worker/prompt 全局预算、运行中 Tool +明确延期到独立、由证据驱动的后续工作:OS/容器沙箱和 CPU/内存等硬资源限制、worker/prompt 全局预算、运行中 Tool 代次租约、通用 watcher 事件限流、偏好记录压缩、完整 metrics/打点平台。它们分别涉及平台执行、安全控制面、 通用服务或数据保留策略,不能以“稳定性修复”为名并入本轮。 @@ -322,7 +324,7 @@ PR5 不新增扩展类型或运行能力,只补齐 Command、Tool、Subagent - Desktop 同时显示待选择和已处理冲突,待选择项排在前面。每个候选明确标识“当前使用”“已选择但当前不可用”“未使用”或“可选”;已选择、 被覆盖以及 Subagent 的“保持不可用”状态都不会因决策完成而消失,用户可直接改选。候选或行为版本变化使冲突指纹变化后, 旧选择失效并重新要求选择;没有变化时不重复打断。 -- 交互式 TUI 不提供 `/external-tools`、`/external-agents` 等平行命令。工具使用通用 `/tools` 入口,并在内容中以 +- 交互式 TUI 不提供 `/external-tools`、`/external-agents` 等按能力分裂的命令。共享控制使用 `/extensions`,工具使用通用 `/tools` 入口,并在内容中以 “外部 AI 应用”分组;Agent 相关能力统一进入 `/agents`,同一列表以文字区分主 Agent、Subagent 和外部 AI 应用, 删除历史 `/subagents` 命令。待选择与当前选择分区显示,只有待选择项进入 状态栏提醒计数;编号操作绑定产生当前视图的快照和稳定 key,改选仍由 owner 做 generation/revision 校验。 diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 9f0c2de5c7..ce898b85bd 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -58,7 +58,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', - ownerFeatures: ['browser-control', 'git', 'mcp', 'miniapp-runtime', 'remote-connect', 'review-platform', 'workspace-search'], + ownerFeatures: ['browser-control', 'git', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'review-platform', 'workspace-search'], }, { depName: 'chrono', ownerFeatures: ['debug-log', 'git', 'remote-connect', 'remote-ssh-concrete', 'review-platform'] }, { depName: 'dirs', ownerFeatures: ['browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 4b5ef1ee07..4ea9030731 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -5,6 +5,7 @@ export const publicApiContractSlices = [ 'bitfun-plugin-extension-contract', 'plugin-runtime-internal-abi', 'opencode-adapter-boundary', + 'external-source-control-contract', 'external-source-command-contract', 'external-source-tool-contract', 'external-source-subagent-contract', @@ -17,6 +18,7 @@ const contractSlices = { bitfunPluginExtension: 'bitfun-plugin-extension-contract', pluginRuntimeInternalAbi: 'plugin-runtime-internal-abi', opencodeAdapterBoundary: 'opencode-adapter-boundary', + externalSourceControlContract: 'external-source-control-contract', externalSourceCommandContract: 'external-source-command-contract', externalSourceToolContract: 'external-source-tool-contract', externalSourceSubagentContract: 'external-source-subagent-contract', @@ -232,6 +234,23 @@ function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { }; } +function externalSourceControlEntry(symbol, owner, consumer, wireImpact = true) { + return { + symbol, + owner, + consumer, + verification: + 'product-domain control contract tests, core safe-mode and generation tests, and Desktop, TUI, Peer Host, Server, and Web control tests', + p0: 'PR1 unified external source control plane and cross-host Safe Mode vertical slice', + contractSlice: contractSlices.externalSourceControlContract, + wireImpact, + rationale: + 'cross-host control needs versioned lifecycle facts and closed actions without leaking capability payloads or ecosystem-specific types', + exit: + 'remove only through a reviewed cross-host control migration with equivalent schema validation, safety, and host behavior tests', + }; +} + function externalIntegrationPolicyEntry( symbol, owner = 'product-domains external integration policy contract owner', @@ -430,6 +449,31 @@ export const externalSourceContractPublicApiEntries = [ ), ); +export const externalSourceControlPublicApiEntries = [ + 'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1', + 'ExternalSourceOperationStage', + 'ExternalSourceRecoveryActionV1', + 'ExternalSourceDiscoveryState', + 'ExternalSourceDesiredState', + 'ExternalSourceReviewState', + 'ExternalSourceRuntimeState', + 'ExternalSourceSupportState', + 'ExternalSourceEffectiveStatus', + 'ExternalCapabilityKindV1', + 'ExternalSourceControlSourceV1', + 'ExternalCapabilityControlV1', + 'ExternalSourceControlSnapshotV1', + 'ExternalSourceSurfaceSnapshotV1', + 'ExternalSourceControlActionV1', + 'ExternalSourceControlRequestV1', +].map((symbol) => + externalSourceControlEntry( + symbol, + 'product-domains external source control contract owner', + 'bitfun-core control composition and neutral Desktop, TUI, Peer Host, Server, and Web surfaces', + ), +); + export const externalSubagentContractPublicApiEntries = [ 'ExternalSubagentLocalId', 'ExternalSubagentCandidateId', @@ -466,6 +510,14 @@ export const externalSubagentContractPublicApiEntries = [ ); export const externalSourceCoordinatorPublicApiEntries = [ + ...['ExternalSourceControlPlane', 'DeferredDiscovery', 'DiscoveryBatch'].map((symbol) => + externalSourceControlEntry( + symbol, + 'external-sources assembly control-plane owner', + 'bitfun-core bounded capability discovery and deferred-completion scheduler', + false, + ), + ), externalSourceEntry( 'ExternalSourceCoordinator', 'external-sources assembly owner', @@ -517,6 +569,23 @@ export const externalSourceCoordinatorPublicApiEntries = [ ]; export const externalSourceCorePublicApiEntries = [ + ...[ + 'ExternalCapabilityKindV1', + 'ExternalSourceControlActionV1', + 'ExternalSourceControlRequestV1', + 'ExternalSourceControlSnapshotV1', + 'ExternalSourceRuntimeState', + 'ExternalSourceSurfaceSnapshotV1', + 'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1', + 'get_external_source_control_snapshot', + 'apply_external_source_control_action', + ].map((symbol) => + externalSourceControlEntry( + symbol, + 'bitfun-core external source control composition facade', + 'BitFun CLI, Desktop, Server, Peer Host, and Web API adapters', + ), + ), ...[ 'ExternalIntegrationAccess', 'ExternalIntegrationMode', @@ -759,6 +828,12 @@ export const publicApiAllowlistRules = [ 'external integration policy contracts must stay ecosystem-neutral, versioned, fail-closed, and explicitly consumer-backed', allowedSymbolEntries: externalIntegrationPolicyPublicApiEntries, }, + { + path: 'src/crates/contracts/product-domains/src/external_source_control.rs', + reason: + 'external source control contracts must stay versioned, capability-neutral, closed-action, and explicitly consumer-backed', + allowedSymbolEntries: externalSourceControlPublicApiEntries, + }, { path: 'src/crates/contracts/product-domains/src/external_sources.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 3a55068f2a..c37e1340e3 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -710,6 +710,14 @@ export function runManifestParserSelfTest({ throw new Error(`services-integrations review-platform must own optional dependency ${dep}`); } } + for (const dep of ['bitfun-services-core']) { + const owner = servicesOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === dep, + ); + if (!owner?.ownerFeatures.includes('process-tree')) { + throw new Error(`services-integrations process-tree must delegate to ${dep}`); + } + } const productDomainsOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'product-domains', ); @@ -868,6 +876,15 @@ export function runManifestParserSelfTest({ const externalSourcePublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_sources.rs', ); + const externalSourceControlPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_source_control.rs', + ); + const externalSourceCoordinatorPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/assembly/external-sources/src/lib.rs', + ); + const externalSourceCorePublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/assembly/core/src/external_sources.rs', + ); const managedPluginActivationPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/assembly/core/src/plugin_runtime.rs', ); @@ -1008,6 +1025,48 @@ export function runManifestParserSelfTest({ if (!publicApiContractSlices.includes('external-source-mcp-contract')) { throw new Error('external MCP contracts must have an independent contract slice'); } + if (!publicApiContractSlices.includes('external-source-control-contract')) { + throw new Error('external source control contracts must have an independent contract slice'); + } + for (const requiredSymbol of [ + 'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1', + 'ExternalSourceControlSnapshotV1', + 'ExternalSourceSurfaceSnapshotV1', + 'ExternalSourceControlActionV1', + 'ExternalSourceControlRequestV1', + 'ExternalSourceOperationStage', + 'ExternalSourceRecoveryActionV1', + ]) { + if (!externalSourceControlPublicApiRule?.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-control-contract' + && entry.consumer + && entry.verification, + )) { + throw new Error(`external source control public API budget is missing a consumer-backed symbol: ${requiredSymbol}`); + } + } + for (const requiredSymbol of ['ExternalSourceControlPlane', 'DeferredDiscovery', 'DiscoveryBatch']) { + if (!externalSourceCoordinatorPublicApiRule?.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-control-contract', + )) { + throw new Error(`external source coordinator public API budget is missing control symbol: ${requiredSymbol}`); + } + } + for (const requiredSymbol of [ + 'ExternalSourceControlSnapshotV1', + 'ExternalSourceControlRequestV1', + 'get_external_source_control_snapshot', + 'apply_external_source_control_action', + ]) { + if (!externalSourceCorePublicApiRule?.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-control-contract', + )) { + throw new Error(`external source core public API budget is missing control symbol: ${requiredSymbol}`); + } + } for (const requiredSymbol of [ 'ExternalMcpServerDefinition', 'ExternalMcpSourceProvider', diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index f8d33a49f8..6a8ae72df6 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -24,6 +24,7 @@ bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["external-sources"] } # CLI framework clap = { workspace = true } diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 1e70eaf2b0..1941de3429 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -66,6 +66,7 @@ pub(crate) enum ActionHandler { ReloadSkills, McpServers, Tools, + Extensions, AcpHelp, Init, History, @@ -368,6 +369,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: false, }, + ActionSpec { + id: "extensions", + name: "External integrations", + aliases: &["/extensions"], + description: "View external source status and Safe Mode", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::Extensions, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Tools", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "acp_help", name: "ACP setup", @@ -1682,6 +1698,9 @@ mod tests { fn extension_management_uses_capability_entries_instead_of_external_commands() { let tools = action_for_alias("/tools", ActionContext::Chat).unwrap(); assert_eq!(tools.handler, ActionHandler::Tools); + let extensions = action_for_alias("/extensions", ActionContext::Chat).unwrap(); + assert_eq!(extensions.handler, ActionHandler::Extensions); + assert!(extensions.description.contains("Safe Mode")); let agents = action_for_alias("/agents", ActionContext::Chat).unwrap(); assert_eq!(agents.handler, ActionHandler::OpenAgentSelector); assert_eq!(agents.description, "Switch modes and manage agents"); diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index b83824e271..90bb5a5f34 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -66,16 +66,19 @@ use bitfun_core::agentic::tools::implementations::skills::{ ModeSkillInfo, SkillInfo, }; use bitfun_core::external_sources::{ - choose_external_subagent_conflict, expand_external_prompt_command, - external_source_conflict_choices, external_source_snapshot, prompt_command_conflict_key, + apply_external_source_control_action, choose_external_subagent_conflict, + expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, + get_external_source_control_snapshot, prompt_command_conflict_key, remember_external_source_conflict_choice, sanitize_external_source_operation_error, set_external_prompt_command_conflict_choice, set_external_subagent_activation, set_external_tool_conflict_choice, set_external_tool_target_decision, subscribe_external_source_updates, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, - ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, + ExternalSourceDiagnosticSeverity, ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSubagentActivationState, ExternalSubagentCompatibilityState, ExternalToolActivationState, ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolRuntimeKind, PromptCommandAvailability, + EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, }; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::render_usage_report_markdown; @@ -201,6 +204,7 @@ pub(crate) struct ChatMode { external_tool_notice_key: Option, external_tool_review_snapshot: Option, external_tool_mutation_rx: Option>, + external_control_mutation_rx: Option>, external_agent_notice_key: Option, external_agent_review_snapshot: Option, external_agent_mutation_rx: Option>, @@ -246,6 +250,7 @@ impl ChatMode { external_tool_notice_key: None, external_tool_review_snapshot: None, external_tool_mutation_rx: None, + external_control_mutation_rx: None, external_agent_notice_key: None, external_agent_review_snapshot: None, external_agent_mutation_rx: None, diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index f55481af89..a20c729db5 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -192,6 +192,17 @@ impl ChatMode { self.handle_external_agent_review(arguments, chat_view, chat_state, rt_handle); return Ok(None); } + let can_route_external_control = builtin_action + .is_some_and(|action| action.handler == ActionHandler::Extensions) + && qualifier != CommandQualifier::External + && (qualifier == CommandQualifier::Builtin + || (external.is_none() + && unresolved_candidates.is_empty() + && !builtin_reconfirmation_required)); + if can_route_external_control { + self.handle_external_control(arguments, chat_view, chat_state, rt_handle); + return Ok(None); + } let native_choice_is_active = unresolved_candidates.iter().any(|candidate| { candidate .native_collision @@ -629,6 +640,9 @@ impl ChatMode { ActionHandler::Tools => { self.handle_external_tool_review("", chat_view, chat_state, rt_handle); } + ActionHandler::Extensions => { + self.handle_external_control("", chat_view, chat_state, rt_handle); + } ActionHandler::AcpHelp => { chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun")); chat_view.set_status(Some( @@ -796,6 +810,6 @@ impl ChatMode { fn action_opens_extension_management(action: &ActionSpec) -> bool { matches!( action.handler, - ActionHandler::Tools | ActionHandler::OpenAgentSelector + ActionHandler::Tools | ActionHandler::Extensions | ActionHandler::OpenAgentSelector ) } diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index b9379aca75..274a7fa5dd 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -1,4 +1,9 @@ // Pure projections and review text derived from the external-source catalog. +use bitfun_product_domains::external_source_control::{ + ExternalSourceDesiredState, ExternalSourceEffectiveStatus, ExternalSourceRecoveryActionV1, + ExternalSourceSupportState, +}; + fn native_command_conflict_key<'a>( execution_domain_id: &str, command_name: &str, @@ -263,6 +268,165 @@ enum ExternalToolReviewAction { }, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExternalControlUiAction { + Show, + Refresh, + SetSafeMode(bool), + SetSourceEnabled { source_key: String, enabled: bool }, +} + +fn parse_external_control_action(arguments: &str) -> Result { + match arguments.split_whitespace().collect::>().as_slice() { + [] | ["status"] => Ok(ExternalControlUiAction::Show), + ["refresh"] => Ok(ExternalControlUiAction::Refresh), + ["safe-mode", "on"] => Ok(ExternalControlUiAction::SetSafeMode(true)), + ["safe-mode", "off"] => Ok(ExternalControlUiAction::SetSafeMode(false)), + ["source", "enable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_key: (*source_key).to_string(), + enabled: true, + }), + ["source", "disable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_key: (*source_key).to_string(), + enabled: false, + }), + _ => Err("usage: /builtin:extensions [status | refresh | safe-mode on | safe-mode off | source enable | source disable ]".to_string()), + } +} + +fn external_control_review_text( + control: &bitfun_core::external_sources::ExternalSourceControlSnapshotV1, +) -> String { + use bitfun_core::external_sources::{ExternalCapabilityKindV1, ExternalSourceRuntimeState}; + + let mut lines = vec![ + "External integrations".to_string(), + String::new(), + format!( + "Safe Mode: {}", + if control.safe_mode { "on" } else { "off" } + ), + format!("Execution domain: {}", control.execution_domain_id), + format!("Generation: {}", control.refresh_generation), + format!("Sources: {}", control.sources.len()), + ]; + if control.safe_mode { + lines.push( + "New external Tool, Agent, and MCP calls are blocked; calls already in progress are not cancelled." + .to_string(), + ); + lines.push( + "Safe Mode applies only to this Host process and execution domain; restarting the Host turns it off." + .to_string(), + ); + } + for source in &control.sources { + let desired = match source.desired { + ExternalSourceDesiredState::Enabled => "enabled", + ExternalSourceDesiredState::Disabled => "disabled", + }; + let effective = match source.effective_status { + ExternalSourceEffectiveStatus::Discovering => "discovering", + ExternalSourceEffectiveStatus::Disabled => "disabled", + ExternalSourceEffectiveStatus::ReviewRequired => "review required", + ExternalSourceEffectiveStatus::Conflict => "conflict", + ExternalSourceEffectiveStatus::Active => "active", + ExternalSourceEffectiveStatus::Degraded => "degraded", + ExternalSourceEffectiveStatus::Unsupported => "unsupported", + ExternalSourceEffectiveStatus::Available => "available", + ExternalSourceEffectiveStatus::Removed => "removed", + }; + lines.push(format!( + "Source {}: {} ({desired}, {effective})", + source.stable_key, source.display_name + )); + } + for capability in &control.capabilities { + let label = match capability.kind { + ExternalCapabilityKindV1::Command => "Commands", + ExternalCapabilityKindV1::Tool => "Tools", + ExternalCapabilityKindV1::Subagent => "Agents", + ExternalCapabilityKindV1::Mcp => "MCP servers", + }; + let runtime = match capability.runtime { + ExternalSourceRuntimeState::NotApplicable => "not applicable", + ExternalSourceRuntimeState::Inactive => "inactive", + ExternalSourceRuntimeState::Starting => "starting", + ExternalSourceRuntimeState::Active => "active", + ExternalSourceRuntimeState::Degraded => "degraded", + ExternalSourceRuntimeState::Quarantined => "quarantined", + ExternalSourceRuntimeState::Unsupported => "unsupported", + }; + let support = match capability.support { + ExternalSourceSupportState::Supported => "", + ExternalSourceSupportState::Partial => ", support: partial", + ExternalSourceSupportState::Unsupported => ", support: unsupported", + ExternalSourceSupportState::Unavailable => ", support: unavailable", + }; + lines.push(format!( + "{label}: {} items, {} review, {} conflicts, {runtime}{support}", + capability.item_count, + capability.pending_review_count, + capability.unresolved_conflict_count, + )); + } + const MAX_STATUS_DETAILS: usize = 4; + if !control.diagnostics.is_empty() { + lines.push(String::new()); + lines.push("Issues".to_string()); + for diagnostic in control.diagnostics.iter().take(MAX_STATUS_DETAILS) { + let severity = match diagnostic.severity { + ExternalSourceDiagnosticSeverity::Info => "info", + ExternalSourceDiagnosticSeverity::Warning => "warning", + ExternalSourceDiagnosticSeverity::Error => "error", + _ => "notice", + }; + lines.push(format!( + " - {severity}: [{}] {}", + diagnostic.code, + external_source_diagnostic_summary(&diagnostic.code) + )); + } + let hidden = control.diagnostics.len().saturating_sub(MAX_STATUS_DETAILS); + if hidden > 0 { + lines.push(format!( + " - {hidden} more; refresh after fixing the listed issue(s)." + )); + } + } + if !control.recovery_actions.is_empty() { + lines.push(String::new()); + lines.push("Recovery".to_string()); + for action in control.recovery_actions.iter().take(MAX_STATUS_DETAILS) { + lines.push(format!( + " - {}", + external_recovery_action_label(action, "extensions") + )); + } + } + lines.push(String::new()); + lines.push("Refresh: /builtin:extensions refresh".to_string()); + lines.push(if control.safe_mode { + "Exit Safe Mode: /builtin:extensions safe-mode off".to_string() + } else { + "Enter Safe Mode: /builtin:extensions safe-mode on".to_string() + }); + lines.push("Enable source: /builtin:extensions source enable ".to_string()); + lines.push("Disable source: /builtin:extensions source disable ".to_string()); + lines.join("\n") +} + +struct ExternalControlMutationResult { + action: ExternalControlUiAction, + result: std::result::Result< + ( + bitfun_core::external_sources::ExternalSourceSurfaceSnapshotV1, + Option, + ), + ExternalSourceOperationError, + >, +} + struct ExternalToolMutationResult { action: ExternalToolReviewAction, result: std::result::Result, @@ -277,6 +441,9 @@ fn external_operation_error_status(surface: &str, error: &ExternalSourceOperatio ExternalSourceOperationErrorCode::HostCapabilityUnavailable => { "This workspace host is read-only for external integrations." } + ExternalSourceOperationErrorCode::TrustRequired => { + "This external integration requires review before it can run." + } ExternalSourceOperationErrorCode::PolicyIncompatible => { "Compatibility settings were written by a newer BitFun version." } @@ -293,14 +460,37 @@ fn external_operation_error_status(surface: &str, error: &ExternalSourceOperatio ExternalSourceOperationErrorCode::Unavailable => { "The external integration is temporarily unavailable." } - ExternalSourceOperationErrorCode::Internal => { + ExternalSourceOperationErrorCode::RuntimeUnavailable + | ExternalSourceOperationErrorCode::DependencyFailed + | ExternalSourceOperationErrorCode::ProcessLost => { + "The external integration runtime is unavailable." + } + ExternalSourceOperationErrorCode::Unsupported + | ExternalSourceOperationErrorCode::IncompatibleVersion => { + "This external integration is not supported by the current BitFun version." + } + ExternalSourceOperationErrorCode::Timeout + | ExternalSourceOperationErrorCode::Overloaded + | ExternalSourceOperationErrorCode::TemporarilyUnavailable => { + "The external integration is temporarily unavailable." + } + ExternalSourceOperationErrorCode::Cancelled => { + "The external integration operation was cancelled." + } + ExternalSourceOperationErrorCode::InvalidResponse + | ExternalSourceOperationErrorCode::Internal => { "BitFun could not complete the external integration update." } }; - let next_step = if error.retryable { - format!(" Run /builtin:{surface} refresh and try again.") - } else { + let next_steps = error + .recovery_actions + .iter() + .map(|action| external_recovery_action_label(action, surface)) + .collect::>(); + let next_step = if next_steps.is_empty() { format!(" Run /builtin:{surface} refresh to review the current state.") + } else { + format!(" Next: {}.", next_steps.join("; ")) }; let reference = error .correlation_id @@ -310,6 +500,29 @@ fn external_operation_error_status(surface: &str, error: &ExternalSourceOperatio format!("{reason}{next_step}{reference}") } +fn external_recovery_action_label( + action: &ExternalSourceRecoveryActionV1, + surface: &str, +) -> String { + match action { + ExternalSourceRecoveryActionV1::Refresh => format!("/builtin:{surface} refresh"), + ExternalSourceRecoveryActionV1::Retry => "retry the operation".to_string(), + ExternalSourceRecoveryActionV1::Review => "review the listed external items".to_string(), + ExternalSourceRecoveryActionV1::ResolveConflict => { + "resolve the listed conflict".to_string() + } + ExternalSourceRecoveryActionV1::InstallRuntime => { + "install or repair the required runtime".to_string() + } + ExternalSourceRecoveryActionV1::ReconnectHost => { + "reconnect or upgrade the execution Host".to_string() + } + ExternalSourceRecoveryActionV1::ExitSafeMode => { + "/builtin:extensions safe-mode off".to_string() + } + } +} + struct ExternalToolTargetSummary<'a> { tools: Vec<&'a ExternalToolCatalogEntry>, } @@ -355,6 +568,7 @@ fn external_tool_target_summaries( fn external_tool_activation_label(activation: &ExternalToolActivationState) -> &'static str { match activation { ExternalToolActivationState::ApprovalRequired => "confirmation required", + ExternalToolActivationState::Declined => "kept disabled", ExternalToolActivationState::Disabled => "disabled", ExternalToolActivationState::Active => "enabled", ExternalToolActivationState::Conflict => "choose between same-name tools", @@ -397,9 +611,12 @@ fn external_tool_next_step(activation: &ExternalToolActivationState) -> &'static ExternalToolActivationState::ApprovalRequired => { "Review the code source and access, then enable it or keep it disabled." } - ExternalToolActivationState::Disabled => { + ExternalToolActivationState::Declined => { "Enable these tools after reviewing their code source and access." } + ExternalToolActivationState::Disabled => { + "Enable this source or its tool capability before using these tools." + } ExternalToolActivationState::Active => { "No action is needed. Disable these tools to stop using this source's tools." } @@ -424,7 +641,10 @@ fn external_tool_default_reason(activation: &ExternalToolActivationState) -> &'s ExternalToolActivationState::ApprovalRequired => { "Review this tool file's access before enabling it." } - ExternalToolActivationState::Disabled => "You chose to disable this tool source.", + ExternalToolActivationState::Declined => "You chose to keep these tools disabled.", + ExternalToolActivationState::Disabled => { + "The source or its tool capability is disabled by policy." + } ExternalToolActivationState::Active => "The tool code is loaded and ready to use.", ExternalToolActivationState::Conflict => "Another tool uses the same name.", ExternalToolActivationState::Unsupported { .. } => { @@ -441,7 +661,7 @@ fn external_tool_default_reason(activation: &ExternalToolActivationState) -> &'s fn external_tool_can_enable(activation: &ExternalToolActivationState) -> bool { matches!( activation, - ExternalToolActivationState::ApprovalRequired | ExternalToolActivationState::Disabled + ExternalToolActivationState::ApprovalRequired | ExternalToolActivationState::Declined ) } diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index 95fdcc8c24..ddf1b51985 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -387,6 +387,154 @@ impl ChatMode { } } + fn handle_external_control( + &mut self, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let action = match parse_external_control_action(arguments) { + Ok(action) => action, + Err(error) => { + chat_view.set_status(Some(error)); + return; + } + }; + if self.external_control_mutation_rx.is_some() { + chat_view.set_status(Some( + "An external integration update is already running; input remains available." + .to_string(), + )); + return; + } + + let workspace = self.workspace_path_for_sync(chat_state); + let expected_preference_revision = self + .external_source_snapshot + .as_ref() + .map(|snapshot| snapshot.preference_revision); + let task_action = action.clone(); + let (sender, receiver) = mpsc::channel(); + rt_handle.spawn(async move { + let result = async { + if matches!(&task_action, ExternalControlUiAction::Show) { + let surface = get_external_source_control_snapshot( + Some(&workspace), + false, + ExternalSourceHostCapabilities::read_write(), + ) + .await?; + return Ok((surface, None)); + } + + let action = match &task_action { + ExternalControlUiAction::Refresh => ExternalSourceControlActionV1::Refresh, + ExternalControlUiAction::SetSafeMode(enabled) => { + ExternalSourceControlActionV1::SetSafeMode { enabled: *enabled } + } + ExternalControlUiAction::SetSourceEnabled { + source_key, + enabled, + } => ExternalSourceControlActionV1::SetSourceEnabled { + source_key: source_key.clone(), + enabled: *enabled, + }, + ExternalControlUiAction::Show => unreachable!(), + }; + let surface = apply_external_source_control_action( + Some(&workspace), + ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: format!("tui-{}", uuid::Uuid::new_v4()), + expected_preference_revision, + action, + }, + ) + .await?; + let catalog = external_source_snapshot(Some(&workspace), false) + .await + .map_err(sanitize_external_source_operation_error)?; + Ok((surface, Some(catalog))) + } + .await; + let _ = sender.send(ExternalControlMutationResult { + action: task_action, + result, + }); + }); + self.external_control_mutation_rx = Some(receiver); + let status = match action { + ExternalControlUiAction::Show => "Reading external integration status", + ExternalControlUiAction::Refresh => "Refreshing external integrations", + ExternalControlUiAction::SetSafeMode(true) => "Entering External Safe Mode", + ExternalControlUiAction::SetSafeMode(false) => "Exiting External Safe Mode", + ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { + "Enabling external source" + } + ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { + "Disabling external source" + } + }; + chat_view.set_status(Some(format!( + "{status}; you can continue typing or cancel other UI work" + ))); + } + + fn poll_external_control_mutation(&mut self, chat_view: &mut ChatView) -> bool { + let outcome = match self + .external_control_mutation_rx + .as_ref() + .map(Receiver::try_recv) + { + Some(Ok(outcome)) => outcome, + Some(Err(MpscTryRecvError::Empty)) | None => return false, + Some(Err(MpscTryRecvError::Disconnected)) => { + self.external_control_mutation_rx = None; + chat_view.set_status(Some( + "External integration status stopped before returning a result; retry /builtin:extensions status." + .to_string(), + )); + return true; + } + }; + self.external_control_mutation_rx = None; + match outcome.result { + Ok((surface, catalog)) => { + if let Some(catalog) = catalog { + self.update_external_source_view(chat_view, &catalog); + self.external_source_snapshot = Some(catalog); + } + chat_view.show_info_popup(external_control_review_text(&surface.control)); + let status = match outcome.action { + ExternalControlUiAction::Show => "External integration status updated", + ExternalControlUiAction::Refresh => "External integrations refreshed", + ExternalControlUiAction::SetSafeMode(true) => "External Safe Mode is active", + ExternalControlUiAction::SetSafeMode(false) => { + "External Safe Mode is off; eligible integrations were reconciled" + } + ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { + "External source enabled" + } + ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { + "External source disabled" + } + }; + chat_view.set_status(Some(status.to_string())); + } + Err(error) => { + tracing::warn!( + error_code = error.code.as_str(), + correlation_id = error.correlation_id.as_deref().unwrap_or("none"), + operation_stage = ?error.stage, + "External integration control action failed" + ); + chat_view.set_status(Some(external_operation_error_status("extensions", &error))); + } + } + true + } + fn handle_external_tool_review( &mut self, arguments: &str, diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index a4ece7466f..9729b62ff1 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -32,59 +32,59 @@ impl ChatMode { let (mut session_id, mut chat_state, mode_migration_notice) = if let Some(ref restore_id) = self.restore_session_id { - // Restore existing session - tracing::info!("Restoring session: {}", restore_id); - let agent = self.agent.clone(); - let rid = restore_id.clone(); - - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - // Restore session in core (loads metadata, messages, managers) - let (summary, effective_workspace_path, migration_notice) = - agent.restore_session_in_current_workspace(&rid).await?; - let effective_workspace = - Some(effective_workspace_path.to_string_lossy().to_string()); - - // Load historical messages for UI display - let transcript = agent.get_transcript(&rid).await.unwrap_or_else(|_| { - bitfun_agent_runtime::sdk::SessionTranscript { - session_id: rid.clone(), - messages: Vec::new(), - } - }); + // Restore existing session + tracing::info!("Restoring session: {}", restore_id); + let agent = self.agent.clone(); + let rid = restore_id.clone(); + + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + // Restore session in core (loads metadata, messages, managers) + let (summary, effective_workspace_path, migration_notice) = + agent.restore_session_in_current_workspace(&rid).await?; + let effective_workspace = + Some(effective_workspace_path.to_string_lossy().to_string()); + + // Load historical messages for UI display + let transcript = agent.get_transcript(&rid).await.unwrap_or_else(|_| { + bitfun_agent_runtime::sdk::SessionTranscript { + session_id: rid.clone(), + messages: Vec::new(), + } + }); + + let state = ChatState::from_session_transcript( + rid.clone(), + summary.session_name, + summary.agent_type, + effective_workspace, + &transcript, + ); + + tracing::info!( + "Session restored: {}, {} messages loaded", + rid, + transcript.messages.len() + ); + + Ok::<_, anyhow::Error>((rid, state, migration_notice)) + }) + })? + } else { + // Create new session + let session_id = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.ensure_session(&self.agent_type)) + })?; + tracing::info!("Core session ready: {}", session_id); - let state = ChatState::from_session_transcript( - rid.clone(), - summary.session_name, - summary.agent_type, - effective_workspace, - &transcript, - ); - - tracing::info!( - "Session restored: {}, {} messages loaded", - rid, - transcript.messages.len() - ); - - Ok::<_, anyhow::Error>((rid, state, migration_notice)) - }) - })? - } else { - // Create new session - let session_id = tokio::task::block_in_place(|| { - rt_handle.block_on(self.agent.ensure_session(&self.agent_type)) - })?; - tracing::info!("Core session ready: {}", session_id); - - let state = ChatState::new( - session_id.clone(), - "CLI Session".to_string(), - self.agent_type.clone(), - self.workspace.clone(), - ); - (session_id, state, None) - }; + let state = ChatState::new( + session_id.clone(), + "CLI Session".to_string(), + self.agent_type.clone(), + self.workspace.clone(), + ); + (session_id, state, None) + }; // Keep ChatMode workspace in sync with the session's effective workspace self.agent_type = chat_state.agent_type.clone(); @@ -244,6 +244,9 @@ impl ChatMode { if self.poll_external_agent_mutation(&mut chat_view) { needs_redraw = true; } + if self.poll_external_control_mutation(&mut chat_view) { + needs_redraw = true; + } let mut external_source_closed = false; if let Some(receiver) = external_source_rx.as_mut() { diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index dd313a12f6..1cb28855d0 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -7,15 +7,16 @@ mod tests { apply_model_selection_feedback, builtin_command_reconfirmation, command_route, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, - external_agent_review_text, external_command_projections, + external_agent_review_text, external_command_projections, external_control_review_text, external_integration_policy_lines, external_operation_error_status, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, external_tool_run_location_label, mark_active_turn_failed, merge_external_agent_mutation_snapshot, mode_change_blocks_typed_submission, mode_change_completion_should_exit, native_command_conflict_key, parse_command_token, parse_external_agent_review_action, - parse_external_tool_review_action, previous_session_mode_change_status, CommandQualifier, - CommandRoute, ExternalAgentReviewAction, ExternalSourceConflictPreferences, + parse_external_control_action, parse_external_tool_review_action, + previous_session_mode_change_status, CommandQualifier, CommandRoute, + ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, }; use crate::actions::{action_conflict_behavior_version, ActionState, ResolvedKeymap}; @@ -23,8 +24,8 @@ mod tests { use crate::config::ShortcutsConfig; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use bitfun_core::external_sources::{ - ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, - ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, + ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceControlSnapshotV1, + ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSubagentActivationState, ExternalToolActivationState, }; @@ -53,6 +54,149 @@ mod tests { } } + #[test] + fn external_control_commands_use_one_small_closed_action_set() { + assert_eq!( + parse_external_control_action("").unwrap(), + ExternalControlUiAction::Show + ); + assert_eq!( + parse_external_control_action("status").unwrap(), + ExternalControlUiAction::Show + ); + assert_eq!( + parse_external_control_action("refresh").unwrap(), + ExternalControlUiAction::Refresh + ); + assert_eq!( + parse_external_control_action("safe-mode on").unwrap(), + ExternalControlUiAction::SetSafeMode(true) + ); + assert_eq!( + parse_external_control_action("safe-mode off").unwrap(), + ExternalControlUiAction::SetSafeMode(false) + ); + assert_eq!( + parse_external_control_action("source disable opencode.commands:project").unwrap(), + ExternalControlUiAction::SetSourceEnabled { + source_key: "opencode.commands:project".to_string(), + enabled: false, + } + ); + assert_eq!( + parse_external_control_action("source enable opencode.commands:project").unwrap(), + ExternalControlUiAction::SetSourceEnabled { + source_key: "opencode.commands:project".to_string(), + enabled: true, + } + ); + assert!(parse_external_control_action("safe-mode toggle").is_err()); + assert!(parse_external_control_action("enable-everything").is_err()); + } + + #[test] + fn external_control_status_projects_shared_runtime_facts() { + let control: ExternalSourceControlSnapshotV1 = serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, + "executionDomainId": "local-user", + "refreshGeneration": 9, + "preferenceRevision": 4, + "safeMode": true, + "hostCapabilities": { + "canRefresh": true, + "canMutatePolicy": true, + "canManageSources": true, + "canApproveRuntime": true, + "canExecuteExternalAssets": true, + "canSetSafeMode": true + }, + "sources": [{ + "stableKey": "opencode.commands:project", + "ecosystemId": "opencode", + "displayName": "OpenCode project commands", + "scope": "project", + "contentVersion": "v1", + "discovery": "current", + "desired": "enabled", + "review": { "state": "not_required" }, + "runtime": "not_applicable", + "support": "supported", + "effectiveStatus": "available" + }], + "capabilities": [{ + "kind": "tool", + "revision": 9, + "itemCount": 2, + "pendingReviewCount": 1, + "unresolvedConflictCount": 0, + "runtime": "inactive", + "support": "supported" + }], + "diagnostics": [], + "recoveryActions": [{ "type": "exit_safe_mode" }] + })) + .unwrap(); + + let text = external_control_review_text(&control); + assert!(text.contains("Safe Mode: on")); + assert!(text.contains("Generation: 9")); + assert!(text.contains("Execution domain: local-user")); + assert!(text.contains("New external Tool, Agent, and MCP calls are blocked")); + assert!(text.contains("restarting the Host turns it off")); + assert!(text.contains("Source opencode.commands:project")); + assert!(text.contains("source disable ")); + assert!(text.contains("Tools: 2 items, 1 review, 0 conflicts, inactive")); + assert!(text.contains("/builtin:extensions safe-mode off")); + } + + #[test] + fn external_control_status_keeps_empty_provider_failures_actionable() { + let control: ExternalSourceControlSnapshotV1 = serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, + "executionDomainId": "local-user", + "refreshGeneration": 10, + "preferenceRevision": 4, + "safeMode": false, + "hostCapabilities": { + "canRefresh": true, + "canMutatePolicy": true, + "canManageSources": true, + "canApproveRuntime": true, + "canExecuteExternalAssets": true, + "canSetSafeMode": true + }, + "sources": [], + "capabilities": [{ + "kind": "tool", + "revision": 10, + "itemCount": 0, + "pendingReviewCount": 0, + "unresolvedConflictCount": 0, + "runtime": "inactive", + "support": "partial" + }], + "diagnostics": [{ + "severity": "error", + "assetKind": "tool", + "code": "external_tool.runtime_unavailable", + "message": "Node runtime is unavailable" + }], + "recoveryActions": [ + { "type": "refresh" }, + { "type": "install_runtime" } + ] + })) + .unwrap(); + + let text = external_control_review_text(&control); + assert!(text.contains("Tools: 0 items, 0 review, 0 conflicts, inactive, support: partial")); + assert!(text.contains("Issues")); + assert!(text.contains("[external_tool.runtime_unavailable]")); + assert!(text.contains("Recovery")); + assert!(text.contains("/builtin:extensions refresh")); + assert!(text.contains("install or repair the required runtime")); + } + fn external_tool_review_snapshot() -> ExternalSourceCatalogSnapshot { serde_json::from_value(serde_json::json!({ "generation": 3, @@ -114,7 +258,7 @@ mod tests { }, "approvalKey": "approval-v2", "decisionKey": "decision-v2", - "activation": { "state": "disabled" } + "activation": { "state": "declined" } }, { "definition": { "id": { @@ -287,27 +431,30 @@ mod tests { ExternalSourceOperationErrorCode::StaleRevision, "raw stale detail", true, - ); + ) + .with_default_recovery_actions(); let policy = ExternalSourceOperationError::new( ExternalSourceOperationErrorCode::PolicyLimited, "raw policy detail", false, - ); + ) + .with_default_recovery_actions(); let internal = ExternalSourceOperationError::new( ExternalSourceOperationErrorCode::Internal, "database password must not be shown", true, ) - .with_correlation_id("external-source-ref-9"); + .with_correlation_id("external-source-ref-9") + .with_default_recovery_actions(); let stale_status = external_operation_error_status("tools", &stale); assert!(stale_status.contains("settings changed")); - assert!(stale_status.contains("refresh and try again")); + assert!(stale_status.contains("/builtin:tools refresh")); assert!(!stale_status.contains("raw stale detail")); let policy_status = external_operation_error_status("agents", &policy); assert!(policy_status.contains("safety policy")); - assert!(policy_status.contains("review the current state")); + assert!(policy_status.contains("review the listed external items")); assert!(!policy_status.contains("raw policy detail")); let internal_status = external_operation_error_status("tools", &internal); diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index 6dd2c27aba..5e65687db9 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -3,13 +3,15 @@ use std::path::PathBuf; use bitfun_core::external_sources::{ - choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, - set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, - set_external_source_enabled, set_external_subagent_activation, - set_external_tool_conflict_choice, set_external_tool_target_decision, - update_external_integration_policy, ExternalIntegrationPolicyMutation, - ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, - ExternalSourcePublicSnapshot, + apply_external_source_control_action, choose_external_mcp_conflict, + choose_external_subagent_conflict, external_source_snapshot, + get_external_source_control_snapshot, set_external_mcp_server_decision, + set_external_prompt_command_conflict_choice, set_external_source_enabled, + set_external_subagent_activation, set_external_tool_conflict_choice, + set_external_tool_target_decision, update_external_integration_policy, + ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, }; use serde_json::Value; @@ -104,13 +106,14 @@ async fn workspace_root( fn public_snapshot( snapshot: bitfun_core::external_sources::ExternalSourceCatalogSnapshot, ) -> ExternalSourceOperationResult { - serde_json::to_value(ExternalSourcePublicSnapshot::from(snapshot)).map_err(|_| { - ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::Internal, - "External source response could not be encoded", - false, - ) - }) + serde_json::to_value(ExternalSourcePublicSnapshot::from(snapshot).into_legacy_v0_compatible()) + .map_err(|_| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + "External source response could not be encoded", + false, + ) + }) } pub(crate) async fn dispatch( @@ -128,9 +131,43 @@ async fn dispatch_inner( args: &Value, state: &PeerHostState, ) -> ExternalSourceOperationResult { + if command == "reveal_external_source_location" { + return Err(ExternalSourceOperationError::host_capability_unavailable( + "This Peer Host cannot reveal source locations in its file manager", + )); + } let request = request_value(args); let workspace = workspace_root(state, request).await?; let workspace = workspace.as_deref(); + if command == "get_external_source_control_snapshot" { + let snapshot = get_external_source_control_snapshot( + workspace, + optional_bool_field(request, "forceRefresh")?.unwrap_or(false), + ExternalSourceHostCapabilities::read_write(), + ) + .await?; + return serde_json::to_value(snapshot).map_err(|_| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + "External source control response could not be encoded", + false, + ) + }); + } + if command == "apply_external_source_control_action_command" { + let control = request.get("control").ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Missing control request") + })?; + let snapshot = + apply_external_source_control_action(workspace, control_request(control)?).await?; + return serde_json::to_value(snapshot).map_err(|_| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + "External source control response could not be encoded", + false, + ) + }); + } let snapshot = match command { "get_external_source_snapshot" => { external_source_snapshot( @@ -245,6 +282,7 @@ async fn dispatch_inner( #[cfg(test)] mod tests { use super::*; + use bitfun_core::external_sources::ExternalSourceControlActionV1; #[test] fn optional_host_fields_reject_wrong_types() { @@ -278,4 +316,34 @@ mod tests { assert_eq!(value["code"], "stale_revision"); assert_eq!(value["retryable"], true); } + + #[test] + fn peer_host_does_not_advertise_desktop_local_reveal_actions() { + assert!(!ExternalSourceHostCapabilities::read_write().can_reveal_source_location); + } + + #[test] + fn peer_control_request_deserializes_the_shared_action() { + let request = serde_json::json!({ + "schemaVersion": 1, + "operationId": "peer-safe-mode", + "expectedPreferenceRevision": 3, + "action": { "type": "set_safe_mode", "enabled": true } + }); + + let control = control_request(&request).unwrap(); + + assert!(matches!( + control.action, + ExternalSourceControlActionV1::SetSafeMode { enabled: true } + )); + } +} + +fn control_request( + request: &Value, +) -> ExternalSourceOperationResult { + serde_json::from_value(request.clone()).map_err(|_| { + ExternalSourceOperationError::invalid_request("Invalid external source control request") + }) } diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 0f020655d7..981c3be896 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -39,6 +39,9 @@ pub(crate) async fn dispatch( "get_agent_profile_config" => config::get_agent_profile_config(args).await, "get_agent_profile_configs" => config::get_agent_profile_configs().await, "get_external_source_snapshot" + | "get_external_source_control_snapshot" + | "reveal_external_source_location" + | "apply_external_source_control_action_command" | "set_external_source_enabled_command" | "set_external_source_conflict_choice_command" | "set_external_tool_target_decision_command" diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 0207159251..723830242d 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -526,6 +526,9 @@ fn external_cli_operation_error(error: String) -> anyhow::Error { ExternalSourceOperationErrorCode::HostCapabilityUnavailable => { "This workspace host is read-only for external integrations." } + ExternalSourceOperationErrorCode::TrustRequired => { + "This external integration requires review before it can run." + } ExternalSourceOperationErrorCode::PolicyIncompatible => { "Compatibility settings were written by a newer BitFun version." } @@ -542,7 +545,25 @@ fn external_cli_operation_error(error: String) -> anyhow::Error { ExternalSourceOperationErrorCode::Unavailable => { "The external integration is temporarily unavailable." } - ExternalSourceOperationErrorCode::Internal => { + ExternalSourceOperationErrorCode::RuntimeUnavailable + | ExternalSourceOperationErrorCode::DependencyFailed + | ExternalSourceOperationErrorCode::ProcessLost => { + "The external integration runtime is unavailable." + } + ExternalSourceOperationErrorCode::Unsupported + | ExternalSourceOperationErrorCode::IncompatibleVersion => { + "This external integration is not supported by the current BitFun version." + } + ExternalSourceOperationErrorCode::Timeout + | ExternalSourceOperationErrorCode::Overloaded + | ExternalSourceOperationErrorCode::TemporarilyUnavailable => { + "The external integration is temporarily unavailable." + } + ExternalSourceOperationErrorCode::Cancelled => { + "The external integration operation was cancelled." + } + ExternalSourceOperationErrorCode::InvalidResponse + | ExternalSourceOperationErrorCode::Internal => { "BitFun could not complete the external integration operation." } }; diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 38d73c1cd5..8e57bcbb37 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -50,6 +50,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "switch_agent", "tools", "mcp_servers", + "extensions", "login", "logout", "help", diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 9fee667e8b..2268b9cb77 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1042,6 +1042,7 @@ impl StartupPage { ActionHandler::ClearConversation | ActionHandler::ReloadSkills | ActionHandler::Tools + | ActionHandler::Extensions | ActionHandler::History | ActionHandler::Interrupt | ActionHandler::ToggleFocusedTool diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 0747f9d601..2407971ba1 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,14 +1,17 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - choose_external_mcp_conflict, choose_external_subagent_conflict, - external_source_location_for_host_action, external_source_snapshot, + apply_external_source_control_action, choose_external_mcp_conflict, + choose_external_subagent_conflict, external_source_location_for_host_action, + external_source_snapshot, + get_external_source_control_snapshot as core_get_external_source_control_snapshot, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, set_external_subagent_activation, set_external_tool_conflict_choice, set_external_tool_target_decision, update_external_integration_policy, ExternalIntegrationPolicyMutation, - ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, - ExternalSourcePublicSnapshot, + ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + ExternalSourceSurfaceSnapshotV1, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use serde::{Deserialize, Serialize}; @@ -22,6 +25,13 @@ pub struct ExternalSourceSnapshotRequest { pub force_refresh: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceControlCommandRequest { + pub workspace_path: Option, + pub control: ExternalSourceControlRequestV1, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSourceEnabledRequest { @@ -120,6 +130,7 @@ pub struct ChooseExternalMcpConflictRequest { } pub type ExternalSourceSnapshotResponse = ExternalSourcePublicSnapshot; +pub type ExternalSourceControlResponse = ExternalSourceSurfaceSnapshotV1; async fn require_local_workspace( workspace_path: Option<&str>, @@ -161,7 +172,7 @@ pub async fn get_external_source_snapshot( let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; external_source_snapshot(workspace, request.force_refresh) .await - .map(Into::into) + .map(|snapshot| ExternalSourcePublicSnapshot::from(snapshot).into_legacy_v0_compatible()) .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } @@ -177,6 +188,27 @@ pub async fn reveal_external_source_location( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn get_external_source_control_snapshot( + request: ExternalSourceSnapshotRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + core_get_external_source_control_snapshot( + workspace, + request.force_refresh, + ExternalSourceHostCapabilities::local_desktop(), + ) + .await +} + +#[tauri::command] +pub async fn apply_external_source_control_action_command( + request: ExternalSourceControlCommandRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + apply_external_source_control_action(workspace, request.control).await +} + #[tauri::command] pub async fn set_external_source_enabled_command( request: SetExternalSourceEnabledRequest, @@ -317,7 +349,9 @@ pub async fn choose_external_mcp_conflict_command( #[cfg(test)] mod tests { use super::*; - use bitfun_core::external_sources::ExternalSourceCatalogSnapshot; + use bitfun_core::external_sources::{ + ExternalSourceCatalogSnapshot, ExternalSourceControlActionV1, + }; #[test] fn desktop_snapshot_never_serializes_prompt_templates() { @@ -348,4 +382,24 @@ mod tests { assert_eq!(value["commands"][0]["definition"]["name"], "review"); assert!(value["commands"][0]["definition"].get("template").is_none()); } + + #[test] + fn desktop_control_command_deserializes_the_shared_action() { + let request: ExternalSourceControlCommandRequest = + serde_json::from_value(serde_json::json!({ + "workspacePath": null, + "control": { + "schemaVersion": 1, + "operationId": "desktop-safe-mode", + "expectedPreferenceRevision": 7, + "action": { "type": "set_safe_mode", "enabled": true } + } + })) + .unwrap(); + + assert!(matches!( + request.control.action, + ExternalSourceControlActionV1::SetSafeMode { enabled: true } + )); + } } diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 0744973758..eef7b596b2 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -149,6 +149,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("add_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("analyze_work_state", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "apply_external_source_control_action_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("apply_patch", RemoteWorkspacePolicy::LegacyUnaudited), ( "archive_all_sessions", @@ -462,6 +466,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "reveal_external_source_location", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "get_external_source_control_snapshot", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "get_file_change_history", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1724,6 +1732,23 @@ mod tests { ); } + #[test] + fn external_source_control_web_command_is_registered() { + const COMMAND: &str = "get_external_source_control_snapshot"; + let web_api = include_str!( + "../../../../web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts" + ); + + assert!( + web_api.contains(&format!("invokeSurfaceSnapshot('{COMMAND}'")), + "Web UI must invoke the stable external-source control command" + ); + assert!( + registered_commands().contains(COMMAND), + "Desktop must register the external-source control command invoked by Web UI" + ); + } + /// `LegacyUnaudited` is a frozen backlog: commands may graduate out of it /// once their remote workspace behavior is audited, but no command may be /// added to it. Do not append to this list; give new commands a real diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6368e175d3..162635b13e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -937,6 +937,8 @@ pub async fn run() { api::editor_ai_api::editor_ai_cancel, get_external_source_snapshot, reveal_external_source_location, + get_external_source_control_snapshot, + apply_external_source_control_action_command, update_external_integration_policy_command, set_external_source_enabled_command, set_external_source_conflict_choice_command, diff --git a/src/apps/server/src/routes/external_sources.rs b/src/apps/server/src/routes/external_sources.rs index 3449ee789f..07c7d84994 100644 --- a/src/apps/server/src/routes/external_sources.rs +++ b/src/apps/server/src/routes/external_sources.rs @@ -1,5 +1,6 @@ use bitfun_core::external_sources::{ - external_source_read_only_snapshot, ExternalSourceOperationError, ExternalSourceOperationResult, + external_source_read_only_snapshot, get_external_source_control_snapshot, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationResult, }; use std::path::PathBuf; @@ -9,6 +10,9 @@ pub(crate) fn supports(method: &str) -> bool { matches!( method, "get_external_source_snapshot" + | "get_external_source_control_snapshot" + | "reveal_external_source_location" + | "apply_external_source_control_action_command" | "set_external_source_enabled_command" | "set_external_source_conflict_choice_command" | "set_external_tool_target_decision_command" @@ -26,7 +30,10 @@ pub(crate) async fn dispatch( params: serde_json::Value, state: &AppState, ) -> ExternalSourceOperationResult { - if method != "get_external_source_snapshot" { + if !matches!( + method, + "get_external_source_snapshot" | "get_external_source_control_snapshot" + ) { return Err(ExternalSourceOperationError::host_capability_unavailable( if supports(method) { "This Server Host exposes external integrations as read-only. Use an authenticated Desktop or Peer Host to change them." @@ -40,16 +47,27 @@ pub(crate) async fn dispatch( .ok_or_else(|| ExternalSourceOperationError::invalid_request("missing request"))?; let workspace = external_workspace_root(state, request)?; let workspace = workspace.as_deref(); - let snapshot = match method { + match method { "get_external_source_snapshot" => { let force_refresh = optional_bool_field(request, "forceRefresh")?; - external_source_read_only_snapshot(workspace, force_refresh).await + let snapshot = external_source_read_only_snapshot(workspace, force_refresh) + .await + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; + serde_json::to_value(snapshot.into_legacy_v0_compatible()) + } + "get_external_source_control_snapshot" => { + let force_refresh = optional_bool_field(request, "forceRefresh")?; + let snapshot = get_external_source_control_snapshot( + workspace, + force_refresh, + ExternalSourceHostCapabilities::read_only_projection(), + ) + .await?; + serde_json::to_value(snapshot) } _ => unreachable!("write and unknown methods are rejected before request parsing"), } - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; - - serde_json::to_value(snapshot).map_err(|_| { + .map_err(|_| { ExternalSourceOperationError::new( bitfun_core::external_sources::ExternalSourceOperationErrorCode::Internal, "External source response could not be encoded", @@ -128,6 +146,8 @@ mod tests { #[test] fn only_external_source_methods_are_claimed() { assert!(supports("get_external_source_snapshot")); + assert!(supports("get_external_source_control_snapshot")); + assert!(supports("apply_external_source_control_action_command")); assert!(supports("update_external_integration_policy_command")); assert!(!supports("open_workspace")); } @@ -186,5 +206,23 @@ mod tests { .await .unwrap_err(); assert_eq!(error.code.as_str(), "host_capability_unavailable"); + + let error = dispatch( + "apply_external_source_control_action_command", + serde_json::json!({ "malformed": true }), + &state, + ) + .await + .unwrap_err(); + assert_eq!(error.code.as_str(), "host_capability_unavailable"); + + let error = dispatch( + "reveal_external_source_location", + serde_json::json!({ "malformed": true }), + &state, + ) + .await + .unwrap_err(); + assert_eq!(error.code.as_str(), "host_capability_unavailable"); } } diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index 75fa0c534f..33d1f58b14 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -250,11 +250,22 @@ fn json_rpc_error_code( ExternalSourceOperationErrorCode::StaleRevision | ExternalSourceOperationErrorCode::Conflict => -32009, ExternalSourceOperationErrorCode::HostUnavailable - | ExternalSourceOperationErrorCode::Unavailable => -32003, - ExternalSourceOperationErrorCode::PolicyIncompatible - | ExternalSourceOperationErrorCode::PolicyLimited => -32010, + | ExternalSourceOperationErrorCode::Unavailable + | ExternalSourceOperationErrorCode::RuntimeUnavailable + | ExternalSourceOperationErrorCode::DependencyFailed + | ExternalSourceOperationErrorCode::Timeout + | ExternalSourceOperationErrorCode::Overloaded + | ExternalSourceOperationErrorCode::ProcessLost + | ExternalSourceOperationErrorCode::TemporarilyUnavailable => -32003, + ExternalSourceOperationErrorCode::Cancelled => -32800, + ExternalSourceOperationErrorCode::TrustRequired + | ExternalSourceOperationErrorCode::PolicyIncompatible + | ExternalSourceOperationErrorCode::PolicyLimited + | ExternalSourceOperationErrorCode::Unsupported + | ExternalSourceOperationErrorCode::IncompatibleVersion => -32010, ExternalSourceOperationErrorCode::NotFound => -32004, - ExternalSourceOperationErrorCode::Internal => -32603, + ExternalSourceOperationErrorCode::InvalidResponse + | ExternalSourceOperationErrorCode::Internal => -32603, } } diff --git a/src/crates/assembly/AGENTS.md b/src/crates/assembly/AGENTS.md index 4ddd1b4aed..03bd25276a 100644 --- a/src/crates/assembly/AGENTS.md +++ b/src/crates/assembly/AGENTS.md @@ -12,7 +12,7 @@ integration, or stable product-domain contracts. | Crate | Responsibility | Local doc | |---|---|---| | `core` | `bitfun-core` compatibility facade and product-full assembly | [AGENTS.md](core/AGENTS.md) | -| `external-sources` | Ecosystem-neutral source lifecycle coordination over capability-specific provider contracts | inherited | +| `external-sources` | Ecosystem-neutral lifecycle owner: capability-specific coordinators plus shared bounded discovery lanes | inherited | | `product-capabilities` | Product capability profiles, tool group facts, service requirements, and harness selections | [AGENTS.md](product-capabilities/AGENTS.md) | ## Placement Rules @@ -28,6 +28,10 @@ integration, or stable product-domain contracts. them with compatibility notes and tests. - Keep assembly additions small and traceable; broad feature growth here is a sign that ownership has not been pushed down far enough. +- Keep external-source capability payloads in their typed contracts and owners. + `ExternalSourceControlPlane` may share scheduling, generation fencing, and + provider isolation, but must not become a generic asset registry or a second + product-state owner. ## Dependency Boundaries diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index b35f117306..8dd2a1c76f 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -69,6 +69,10 @@ SessionManager -> Session -> DialogTurn -> ModelRound composition files allowed to select ecosystem adapters for their respective capability contracts. Product surfaces consume product-level views and must not import adapter or Host ABI types. +- External-source Desktop, TUI, Peer, and Server surfaces share the versioned + product-domain control DTO and closed generic actions. Capability-specific + approvals and conflict choices remain typed owner operations; do not add a + second surface-specific lifecycle model or arbitrary control payload. - Remote/service changes must keep external protocol lifecycle, workspace projection, scheduler/session restore, terminal pre-warm, and product execution boundaries explicit. diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 59044809fb..95067388c6 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -9,6 +9,11 @@ pub use bitfun_product_domains::external_integration_policy::{ ExternalIntegrationPolicyScope, ExternalIntegrationPolicySnapshot, ExternalIntegrationPolicyStatus, }; +pub use bitfun_product_domains::external_source_control::{ + ExternalCapabilityKindV1, ExternalSourceControlActionV1, ExternalSourceControlRequestV1, + ExternalSourceControlSnapshotV1, ExternalSourceRuntimeState, ExternalSourceSurfaceSnapshotV1, + EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, +}; pub use bitfun_product_domains::external_sources::{ prompt_command_conflict_key, EcosystemId, ExpandedPromptCommand, ExternalIntegrationCapabilityId, ExternalMcpActivationState, ExternalMcpApprovalRequest, @@ -44,10 +49,9 @@ use crate::external_tools::{ }; use crate::service::config::{subscribe_config_updates, ConfigUpdateEvent}; use bitfun_external_sources::{ - ExternalMcpCoordinator, ExternalMcpDiscoveryRequest, ExternalMcpDiscoveryResult, - ExternalSourceCoordinator, ExternalSourceDiscoveryRequest, ExternalSourceDiscoveryResult, - ExternalSubagentCoordinator, ExternalSubagentDiscoveryRequest, ExternalSubagentDiscoveryResult, - ExternalToolCoordinator, ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, + DeferredDiscovery, ExternalMcpDiscoveryResult, ExternalSourceControlPlane, + ExternalSourceCoordinator, ExternalSourceDiscoveryResult, ExternalSubagentDiscoveryResult, + ExternalToolDiscoveryResult, }; use bitfun_opencode_adapter::{ OpenCodeCommandProvider, OpenCodeMcpProvider, OpenCodeSubagentProvider, OpenCodeToolProvider, @@ -66,8 +70,7 @@ use bitfun_product_domains::external_subagents::ExternalSubagentSourceProvider; use bitfun_services_core::json_store::JsonFileStore; use bitfun_services_integrations::file_watch::{FileWatchService, FileWatcherConfig}; use dashmap::{mapref::entry::Entry, DashMap}; -use futures::future::{join_all, BoxFuture, Shared}; -use futures::FutureExt; +use futures::future::join_all; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -402,31 +405,6 @@ impl ExternalSourcePreferenceStore { } } -type SharedDiscoveryTask = Shared>; -type SharedToolDiscoveryTask = Shared>; -type SharedSubagentDiscoveryTask = Shared>; -type SharedMcpDiscoveryTask = Shared>; - -struct InFlightDiscovery { - task: SharedDiscoveryTask, - wake_scheduled: bool, -} - -struct InFlightToolDiscovery { - task: SharedToolDiscoveryTask, - wake_scheduled: bool, -} - -struct InFlightSubagentDiscovery { - task: SharedSubagentDiscoveryTask, - wake_scheduled: bool, -} - -struct InFlightMcpDiscovery { - task: SharedMcpDiscoveryTask, - wake_scheduled: bool, -} - #[derive(Clone, Copy)] enum WorkerRecoveryPolicy { Preserve, @@ -462,6 +440,30 @@ fn workspace_policy_key_from_route(route: &str) -> Option { )) } +fn external_source_safe_mode_key(execution_domain_id: &str, workspace_route: &str) -> String { + format!("{execution_domain_id}\u{1f}{workspace_route}") +} + +fn external_source_safe_mode_enabled_for(execution_domain_id: &str, workspace_route: &str) -> bool { + safe_mode_workspaces().contains_key(&external_source_safe_mode_key( + execution_domain_id, + workspace_route, + )) +} + +fn set_external_source_safe_mode_for( + execution_domain_id: &str, + workspace_route: &str, + enabled: bool, +) { + let key = external_source_safe_mode_key(execution_domain_id, workspace_route); + if enabled { + safe_mode_workspaces().insert(key, ()); + } else { + safe_mode_workspaces().remove(&key); + } +} + fn integration_policy_snapshot( preferences: &ExternalSourcesConfig, workspace_root: Option<&Path>, @@ -630,27 +632,12 @@ struct WorkspaceExternalSourceService { profile: ExternalSourceServiceProfile, workspace_root: Option, execution_domain_id: ExecutionDomainId, - coordinator: Arc>, - tool_coordinator: Arc>, - subagent_coordinator: Arc>, - mcp_coordinator: Arc>, + control_plane: Arc, snapshot: StdMutex, updates: broadcast::Sender, watch_states: tokio::sync::Mutex>, refresh_gate: tokio::sync::Mutex<()>, product_rebuild_gate: tokio::sync::Mutex<()>, - discovery_tasks: tokio::sync::Mutex< - BTreeMap, - >, - tool_discovery_tasks: tokio::sync::Mutex< - BTreeMap, - >, - subagent_discovery_tasks: tokio::sync::Mutex< - BTreeMap, - >, - mcp_discovery_tasks: tokio::sync::Mutex< - BTreeMap, - >, mcp_runtime: Arc, active_mcp_runtime_ids: tokio::sync::Mutex>, initial_refresh_completed: AtomicBool, @@ -694,44 +681,47 @@ impl WorkspaceExternalSourceService { .iter() .filter_map(|registration| registration.command_provider.as_ref().map(Arc::clone)) .collect(); - let mut coordinator = ExternalSourceCoordinator::new(context.clone(), providers)?; let tool_providers: Vec> = registrations .iter() .filter_map(|registration| registration.tool_provider.as_ref().map(Arc::clone)) .collect(); - let mut tool_coordinator = ExternalToolCoordinator::new(context.clone(), tool_providers)?; let subagent_providers: Vec> = registrations .iter() .filter_map(|registration| registration.subagent_provider.as_ref().map(Arc::clone)) .collect(); - let mut subagent_coordinator = - ExternalSubagentCoordinator::new(context.clone(), subagent_providers)?; let mcp_providers: Vec> = registrations .iter() .filter_map(|registration| registration.mcp_provider.as_ref().map(Arc::clone)) .collect(); - let mut mcp_coordinator = ExternalMcpCoordinator::new(context, mcp_providers)?; + let control_plane = Arc::new(ExternalSourceControlPlane::new( + context, + providers, + tool_providers, + subagent_providers, + mcp_providers, + )?); let preferences = read_external_sources_config().await?; let suppressed_sources = preferences .suppressed_source_keys .iter() .cloned() .collect::>(); - coordinator.replace_suppressed_sources(suppressed_sources.clone()); - tool_coordinator.replace_suppressed_sources(suppressed_sources.clone()); - subagent_coordinator.replace_suppressed_sources(suppressed_sources.clone()); - mcp_coordinator.replace_suppressed_sources(suppressed_sources); - coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); - coordinator.replace_conflict_lineage_current_keys( - preferences.conflict_lineage_current_keys.clone(), - ); - coordinator.replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids.clone()); + control_plane.replace_suppressed_sources(suppressed_sources); + control_plane.commands_mut(|coordinator| { + coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); + coordinator.replace_conflict_lineage_current_keys( + preferences.conflict_lineage_current_keys.clone(), + ); + coordinator + .replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids.clone()); + }); let mut initial_snapshot = merge_tool_state( - coordinator.snapshot(), - &tool_coordinator.snapshot(), + control_plane.commands(|coordinator| coordinator.snapshot()), + &control_plane.tools(|coordinator| coordinator.snapshot()), ExternalToolProductState::default(), ); - initial_snapshot.subagent_generation = subagent_coordinator.snapshot().generation; + initial_snapshot.subagent_generation = + control_plane.subagents(|coordinator| coordinator.snapshot().generation); initial_snapshot.preference_revision = preferences.preference_revision; initial_snapshot.integration_policy = integration_policy_snapshot(&preferences, workspace_root.as_deref())?; @@ -740,19 +730,12 @@ impl WorkspaceExternalSourceService { profile, workspace_root, execution_domain_id, - coordinator: Arc::new(StdMutex::new(coordinator)), - tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), - subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), - mcp_coordinator: Arc::new(StdMutex::new(mcp_coordinator)), + control_plane, snapshot: StdMutex::new(initial_snapshot), updates, watch_states: tokio::sync::Mutex::new(BTreeMap::new()), refresh_gate: tokio::sync::Mutex::new(()), product_rebuild_gate: tokio::sync::Mutex::new(()), - discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), - tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), - subagent_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), - mcp_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), mcp_runtime: Arc::new(BitFunExternalMcpRuntime), active_mcp_runtime_ids: tokio::sync::Mutex::new(BTreeSet::new()), initial_refresh_completed: AtomicBool::new(false), @@ -821,7 +804,7 @@ impl WorkspaceExternalSourceService { }; let mut requests = Vec::new(); let mut disabled_command_results = Vec::new(); - for request in lock_coordinator(&self.coordinator).discovery_requests() { + for request in lock_coordinator(&self.control_plane).discovery_requests() { if integration_capability_is_discoverable( &policy, request.ecosystem_id().as_str(), @@ -832,10 +815,9 @@ impl WorkspaceExternalSourceService { disabled_command_results.push(request.disabled()); } } - let scheduled = self.prepare_discovery_tasks(requests).await; let mut tool_requests = Vec::new(); let mut disabled_tool_results = Vec::new(); - for request in lock_tool_coordinator(&self.tool_coordinator).discovery_requests() { + for request in lock_tool_coordinator(&self.control_plane).discovery_requests() { if integration_capability_is_discoverable( &policy, request.ecosystem_id().as_str(), @@ -846,10 +828,9 @@ impl WorkspaceExternalSourceService { disabled_tool_results.push(request.disabled()); } } - let tool_scheduled = self.prepare_tool_discovery_tasks(tool_requests).await; let mut subagent_requests = Vec::new(); let mut disabled_subagent_results = Vec::new(); - for request in lock_subagent_coordinator(&self.subagent_coordinator).discovery_requests() { + for request in lock_subagent_coordinator(&self.control_plane).discovery_requests() { if integration_capability_is_discoverable( &policy, request.ecosystem_id().as_str(), @@ -860,12 +841,9 @@ impl WorkspaceExternalSourceService { disabled_subagent_results.push(request.disabled()); } } - let subagent_scheduled = self - .prepare_subagent_discovery_tasks(subagent_requests) - .await; let mut mcp_requests = Vec::new(); let mut disabled_mcp_results = Vec::new(); - for request in lock_mcp_coordinator(&self.mcp_coordinator).discovery_requests() { + for request in lock_mcp_coordinator(&self.control_plane).discovery_requests() { if integration_capability_is_discoverable( &policy, request.ecosystem_id().as_str(), @@ -876,26 +854,42 @@ impl WorkspaceExternalSourceService { disabled_mcp_results.push(request.disabled()); } } - let mcp_scheduled = self.prepare_mcp_discovery_tasks(mcp_requests).await; - let (polled, tool_polled, subagent_polled, mcp_polled) = tokio::join!( - poll_discovery_tasks(scheduled, PROVIDER_DISCOVERY_TIMEOUT), - poll_tool_discovery_tasks(tool_scheduled, PROVIDER_DISCOVERY_TIMEOUT), - poll_subagent_discovery_tasks(subagent_scheduled, PROVIDER_DISCOVERY_TIMEOUT), - poll_mcp_discovery_tasks(mcp_scheduled, PROVIDER_DISCOVERY_TIMEOUT), + let (command_batch, tool_batch, subagent_batch, mcp_batch) = tokio::join!( + self.control_plane + .discover_commands(requests, PROVIDER_DISCOVERY_TIMEOUT), + self.control_plane + .discover_tools(tool_requests, PROVIDER_DISCOVERY_TIMEOUT), + self.control_plane + .discover_subagents(subagent_requests, PROVIDER_DISCOVERY_TIMEOUT), + self.control_plane + .discover_mcp(mcp_requests, PROVIDER_DISCOVERY_TIMEOUT), ); - let mut results = self.finish_discovery_poll(polled).await; + let mut results = command_batch.immediate; results.append(&mut disabled_command_results); - let mut tool_results = self.finish_tool_discovery_poll(tool_polled).await; + let mut tool_results = tool_batch.immediate; tool_results.append(&mut disabled_tool_results); - let mut subagent_results = self.finish_subagent_discovery_poll(subagent_polled).await; + let mut subagent_results = subagent_batch.immediate; subagent_results.append(&mut disabled_subagent_results); - let mut mcp_results = self.finish_mcp_discovery_poll(mcp_polled).await; + let mut mcp_results = mcp_batch.immediate; mcp_results.append(&mut disabled_mcp_results); - let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_results(results); - lock_tool_coordinator(&self.tool_coordinator).apply_discovery_results(tool_results); - let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator) + let command_snapshot = + lock_coordinator(&self.control_plane).apply_discovery_results(results); + lock_tool_coordinator(&self.control_plane).apply_discovery_results(tool_results); + let subagent_snapshot = lock_subagent_coordinator(&self.control_plane) .apply_discovery_results(subagent_results); - lock_mcp_coordinator(&self.mcp_coordinator).apply_discovery_results(mcp_results); + lock_mcp_coordinator(&self.control_plane).apply_discovery_results(mcp_results); + for deferred in command_batch.deferred { + self.schedule_deferred_command_discovery(deferred); + } + for deferred in tool_batch.deferred { + self.schedule_deferred_tool_discovery(deferred); + } + for deferred in subagent_batch.deferred { + self.schedule_deferred_subagent_discovery(deferred); + } + for deferred in mcp_batch.deferred { + self.schedule_deferred_mcp_discovery(deferred); + } self.schedule_subagent_last_valid_expiry(&subagent_snapshot); self.ensure_watch_roots(&policy).await; let snapshot = self @@ -949,7 +943,7 @@ impl WorkspaceExternalSourceService { worker_recovery_targets: &BTreeSet, ) -> Result { let _rebuild_guard = self.product_rebuild_gate.lock().await; - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); let mut preferences = read_external_sources_config().await?; let mut policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; if self.profile == ExternalSourceServiceProfile::ReadOnlyProjection { @@ -962,23 +956,33 @@ impl WorkspaceExternalSourceService { .is_empty(); let command_active_ecosystems = ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_COMMAND); + let safe_mode = self.safe_mode_enabled(); let tool_discoverable = !ecosystems_with_discoverable_capability(&policy, EXTERNAL_CAPABILITY_TOOL).is_empty(); - let tool_active_ecosystems = - ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_TOOL); + let tool_active_ecosystems = if safe_mode { + BTreeSet::new() + } else { + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_TOOL) + }; let subagent_discoverable = !ecosystems_with_discoverable_capability(&policy, EXTERNAL_CAPABILITY_SUBAGENT) .is_empty(); - let subagent_active_ecosystems = - ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_SUBAGENT); + let subagent_active_ecosystems = if safe_mode { + BTreeSet::new() + } else { + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_SUBAGENT) + }; let mcp_discoverable = !ecosystems_with_discoverable_capability(&policy, EXTERNAL_CAPABILITY_MCP).is_empty(); - let mcp_active_ecosystems = - ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_MCP); + let mcp_active_ecosystems = if safe_mode { + BTreeSet::new() + } else { + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_MCP) + }; let mut state = reconcile_external_tools( self.workspace_root.as_deref(), self.execution_domain_id.as_str(), - &self.tool_coordinator, + &self.control_plane, ExternalToolDecisions { active_ecosystems: &tool_active_ecosystems, approved_targets: &preferences.approved_tool_targets, @@ -999,9 +1003,9 @@ impl WorkspaceExternalSourceService { source: None, }); } - let tool_snapshot = lock_tool_coordinator(&self.tool_coordinator).snapshot(); + let tool_snapshot = lock_tool_coordinator(&self.control_plane).snapshot(); let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, state); - let mcp_snapshot = lock_mcp_coordinator(&self.mcp_coordinator).snapshot(); + let mcp_snapshot = lock_mcp_coordinator(&self.control_plane).snapshot(); let mcp_workspace_key = workspace_route_key(self.workspace_root.as_deref()); let native_mcp_candidates = if !mcp_active_ecosystems.is_empty() { load_native_mcp_candidates().await @@ -1044,7 +1048,7 @@ impl WorkspaceExternalSourceService { }; self.reconcile_mcp_runtime(&mut mcp_state).await; merge_mcp_state(&mut snapshot, &mcp_snapshot, mcp_state); - let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).snapshot(); + let subagent_snapshot = lock_subagent_coordinator(&self.control_plane).snapshot(); let mut subagent_state = reconcile_external_subagents( self.workspace_root.as_deref(), self.execution_domain_id.as_str(), @@ -1222,7 +1226,7 @@ impl WorkspaceExternalSourceService { let mcp_active_ecosystems = ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_MCP); - let tool_snapshot = lock_tool_coordinator(&self.tool_coordinator).snapshot(); + let tool_snapshot = lock_tool_coordinator(&self.control_plane).snapshot(); let tool_state = project_external_tools_read_only( self.execution_domain_id.as_str(), &tool_snapshot, @@ -1235,7 +1239,7 @@ impl WorkspaceExternalSourceService { ); let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, tool_state); - let mcp_snapshot = lock_mcp_coordinator(&self.mcp_coordinator).snapshot(); + let mcp_snapshot = lock_mcp_coordinator(&self.control_plane).snapshot(); let mcp_workspace_key = workspace_route_key(self.workspace_root.as_deref()); let mut mcp_state = reconcile_external_mcp_catalog( self.execution_domain_id.as_str(), @@ -1263,7 +1267,7 @@ impl WorkspaceExternalSourceService { } merge_mcp_state(&mut snapshot, &mcp_snapshot, mcp_state); - let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).snapshot(); + let subagent_snapshot = lock_subagent_coordinator(&self.control_plane).snapshot(); let subagent_state = project_external_subagents_read_only( self.workspace_root.as_deref(), self.execution_domain_id.as_str(), @@ -1403,7 +1407,7 @@ impl WorkspaceExternalSourceService { continue; } - let coordinator = Arc::clone(&self.mcp_coordinator); + let coordinator = Arc::clone(&self.control_plane); let server_id = candidate.definition.id.clone(); let behavior_version = candidate.definition.behavior_version.clone(); let prepared = tokio::task::spawn_blocking(move || { @@ -1446,339 +1450,143 @@ impl WorkspaceExternalSourceService { *self.active_mcp_runtime_ids.lock().await = managed; } - async fn prepare_discovery_tasks( - &self, - requests: Vec, - ) -> Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedDiscoveryTask, - bool, - )> { - let mut tasks = self.discovery_tasks.lock().await; - requests - .into_iter() - .map(|request| { - let provider_id = request.provider_id().clone(); - if let Some(in_flight) = tasks.get(&provider_id) { - return (provider_id, in_flight.task.clone(), false); - } - let task = spawn_discovery_task(request); - tasks.insert( - provider_id.clone(), - InFlightDiscovery { - task: task.clone(), - wake_scheduled: false, - }, - ); - (provider_id, task, true) - }) - .collect() - } - - async fn finish_discovery_poll( + fn schedule_deferred_command_discovery( self: &Arc, - polled: Vec, - ) -> Vec { - let mut results = Vec::with_capacity(polled.len()); - let mut wake_tasks = Vec::new(); - let mut tasks = self.discovery_tasks.lock().await; - for poll in polled { - match poll { - DiscoveryPoll::Complete(result) => { - tasks.remove(&result.provider_id().clone()); - results.push(result); - } - DiscoveryPoll::InFlight(provider_id) => { - results.push(discovery_failure( - provider_id, - "external_source.discovery_in_progress", - "provider discovery is still running; using its last valid version", - )); - } - DiscoveryPoll::TimedOut(provider_id) => { - if let Some(in_flight) = tasks.get_mut(&provider_id) { - if !in_flight.wake_scheduled { - in_flight.wake_scheduled = true; - wake_tasks.push((provider_id.clone(), in_flight.task.clone())); - } - } - results.push(discovery_failure( - provider_id, - "external_source.discovery_timeout", - "provider discovery exceeded the 5 second deadline", - )); - } - } - } - drop(tasks); - for (provider_id, task) in wake_tasks { - let weak = Arc::downgrade(self); - tokio::spawn(async move { - let result = task.await; - let Some(service) = weak.upgrade() else { + deferred: DeferredDiscovery, + ) { + let weak = Arc::downgrade(self); + let control_plane = Arc::clone(&self.control_plane); + tokio::spawn(async move { + let mut deferred = deferred; + loop { + let Some((completed, observer)) = control_plane.complete_command(deferred).await + else { return; }; - service - .complete_deferred_discovery(provider_id, result) - .await; - }); - } - results - } - - async fn prepare_tool_discovery_tasks( - &self, - requests: Vec, - ) -> Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedToolDiscoveryTask, - bool, - )> { - let mut tasks = self.tool_discovery_tasks.lock().await; - requests - .into_iter() - .map(|request| { - let provider_id = request.provider_id().clone(); - if let Some(in_flight) = tasks.get(&provider_id) { - return (provider_id, in_flight.task.clone(), false); + { + let Some(service) = weak.upgrade() else { + return; + }; + let _refresh_guard = service.refresh_gate.lock().await; + let Some(result) = control_plane.finalize_command(completed).await else { + return; + }; + service.complete_deferred_discovery(result).await; } - let task = spawn_tool_discovery_task(request); - tasks.insert( - provider_id.clone(), - InFlightToolDiscovery { - task: task.clone(), - wake_scheduled: false, - }, - ); - (provider_id, task, true) - }) - .collect() + let Some(observer) = observer else { + return; + }; + let Some(next) = control_plane.resume_abandoned_command(observer).await else { + return; + }; + deferred = next; + } + }); } - async fn finish_tool_discovery_poll( + fn schedule_deferred_tool_discovery( self: &Arc, - polled: Vec, - ) -> Vec { - let mut results = Vec::with_capacity(polled.len()); - let mut wake_tasks = Vec::new(); - let mut tasks = self.tool_discovery_tasks.lock().await; - for poll in polled { - match poll { - ToolDiscoveryPoll::Complete(result) => { - tasks.remove(&result.provider_id().clone()); - results.push(result); - } - ToolDiscoveryPoll::InFlight(provider_id) => results.push(tool_discovery_failure( - provider_id, - "external_tool.discovery_in_progress", - "tool provider discovery is still running; using its last valid version", - )), - ToolDiscoveryPoll::TimedOut(provider_id) => { - if let Some(in_flight) = tasks.get_mut(&provider_id) { - if !in_flight.wake_scheduled { - in_flight.wake_scheduled = true; - wake_tasks.push((provider_id.clone(), in_flight.task.clone())); - } - } - results.push(tool_discovery_failure( - provider_id, - "external_tool.discovery_timeout", - "tool provider discovery exceeded the 5 second deadline", - )); - } - } - } - drop(tasks); - for (provider_id, task) in wake_tasks { - let weak = Arc::downgrade(self); - tokio::spawn(async move { - let result = task.await; - let Some(service) = weak.upgrade() else { + deferred: DeferredDiscovery, + ) { + let weak = Arc::downgrade(self); + let control_plane = Arc::clone(&self.control_plane); + tokio::spawn(async move { + let mut deferred = deferred; + loop { + let Some((completed, observer)) = control_plane.complete_tool(deferred).await + else { return; }; - service - .complete_deferred_tool_discovery(provider_id, result) - .await; - }); - } - results - } - - async fn prepare_subagent_discovery_tasks( - &self, - requests: Vec, - ) -> Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedSubagentDiscoveryTask, - bool, - )> { - let mut tasks = self.subagent_discovery_tasks.lock().await; - requests - .into_iter() - .map(|request| { - let provider_id = request.provider_id().clone(); - if let Some(in_flight) = tasks.get(&provider_id) { - return (provider_id, in_flight.task.clone(), false); + { + let Some(service) = weak.upgrade() else { + return; + }; + let _refresh_guard = service.refresh_gate.lock().await; + let Some(result) = control_plane.finalize_tool(completed).await else { + return; + }; + service.complete_deferred_tool_discovery(result).await; } - let task = spawn_subagent_discovery_task(request); - tasks.insert( - provider_id.clone(), - InFlightSubagentDiscovery { - task: task.clone(), - wake_scheduled: false, - }, - ); - (provider_id, task, true) - }) - .collect() + let Some(observer) = observer else { + return; + }; + let Some(next) = control_plane.resume_abandoned_tool(observer).await else { + return; + }; + deferred = next; + } + }); } - async fn finish_subagent_discovery_poll( + fn schedule_deferred_subagent_discovery( self: &Arc, - polled: Vec, - ) -> Vec { - let mut results = Vec::with_capacity(polled.len()); - let mut wake_tasks = Vec::new(); - let mut tasks = self.subagent_discovery_tasks.lock().await; - for poll in polled { - match poll { - SubagentDiscoveryPoll::Complete(result) => { - tasks.remove(&result.provider_id().clone()); - results.push(result); - } - SubagentDiscoveryPoll::InFlight(provider_id) => { - results.push(subagent_discovery_failure( - provider_id, - "external_subagent.discovery_in_progress", - "subagent provider discovery is still running; using its last valid version", - )); - } - SubagentDiscoveryPoll::TimedOut(provider_id) => { - if let Some(in_flight) = tasks.get_mut(&provider_id) { - if !in_flight.wake_scheduled { - in_flight.wake_scheduled = true; - wake_tasks.push((provider_id.clone(), in_flight.task.clone())); - } - } - results.push(subagent_discovery_failure( - provider_id, - "external_subagent.discovery_timeout", - "subagent provider discovery exceeded the 5 second deadline", - )); - } - } - } - drop(tasks); - for (provider_id, task) in wake_tasks { - let weak = Arc::downgrade(self); - tokio::spawn(async move { - let result = task.await; - let Some(service) = weak.upgrade() else { + deferred: DeferredDiscovery, + ) { + let weak = Arc::downgrade(self); + let control_plane = Arc::clone(&self.control_plane); + tokio::spawn(async move { + let mut deferred = deferred; + loop { + let Some((completed, observer)) = control_plane.complete_subagent(deferred).await + else { return; }; - service - .complete_deferred_subagent_discovery(provider_id, result) - .await; - }); - } - results - } - - async fn prepare_mcp_discovery_tasks( - &self, - requests: Vec, - ) -> Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedMcpDiscoveryTask, - bool, - )> { - let mut tasks = self.mcp_discovery_tasks.lock().await; - requests - .into_iter() - .map(|request| { - let provider_id = request.provider_id().clone(); - if let Some(in_flight) = tasks.get(&provider_id) { - return (provider_id, in_flight.task.clone(), false); + { + let Some(service) = weak.upgrade() else { + return; + }; + let _refresh_guard = service.refresh_gate.lock().await; + let Some(result) = control_plane.finalize_subagent(completed).await else { + return; + }; + service.complete_deferred_subagent_discovery(result).await; } - let task = spawn_mcp_discovery_task(request); - tasks.insert( - provider_id.clone(), - InFlightMcpDiscovery { - task: task.clone(), - wake_scheduled: false, - }, - ); - (provider_id, task, true) - }) - .collect() + let Some(observer) = observer else { + return; + }; + let Some(next) = control_plane.resume_abandoned_subagent(observer).await else { + return; + }; + deferred = next; + } + }); } - async fn finish_mcp_discovery_poll( + fn schedule_deferred_mcp_discovery( self: &Arc, - polled: Vec, - ) -> Vec { - let mut results = Vec::with_capacity(polled.len()); - let mut wake_tasks = Vec::new(); - let mut tasks = self.mcp_discovery_tasks.lock().await; - for poll in polled { - match poll { - McpDiscoveryPoll::Complete(result) => { - tasks.remove(&result.provider_id().clone()); - results.push(result); - } - McpDiscoveryPoll::InFlight(provider_id) => { - results.push(mcp_discovery_failure( - provider_id, - "external_mcp.discovery_in_progress", - "MCP provider discovery is still running; using its last valid version", - )); - } - McpDiscoveryPoll::TimedOut(provider_id) => { - if let Some(in_flight) = tasks.get_mut(&provider_id) { - if !in_flight.wake_scheduled { - in_flight.wake_scheduled = true; - wake_tasks.push((provider_id.clone(), in_flight.task.clone())); - } - } - results.push(mcp_discovery_failure( - provider_id, - "external_mcp.discovery_timeout", - "MCP provider discovery exceeded the 5 second deadline", - )); + deferred: DeferredDiscovery, + ) { + let weak = Arc::downgrade(self); + let control_plane = Arc::clone(&self.control_plane); + tokio::spawn(async move { + let mut deferred = deferred; + loop { + let Some((completed, observer)) = control_plane.complete_mcp(deferred).await else { + return; + }; + { + let Some(service) = weak.upgrade() else { + return; + }; + let _refresh_guard = service.refresh_gate.lock().await; + let Some(result) = control_plane.finalize_mcp(completed).await else { + return; + }; + service.complete_deferred_mcp_discovery(result).await; } - } - } - drop(tasks); - for (provider_id, task) in wake_tasks { - let weak = Arc::downgrade(self); - tokio::spawn(async move { - let result = task.await; - let Some(service) = weak.upgrade() else { + let Some(observer) = observer else { return; }; - service - .complete_deferred_mcp_discovery(provider_id, result) - .await; - }); - } - results + let Some(next) = control_plane.resume_abandoned_mcp(observer).await else { + return; + }; + deferred = next; + } + }); } - async fn complete_deferred_discovery( - &self, - provider_id: bitfun_product_domains::external_sources::ProviderId, - result: ExternalSourceDiscoveryResult, - ) { - let _refresh_guard = self.refresh_gate.lock().await; - if self - .discovery_tasks - .lock() - .await - .remove(&provider_id) - .is_none() - { - return; - } + async fn complete_deferred_discovery(&self, result: ExternalSourceDiscoveryResult) { + let provider_id = result.provider_id().clone(); let Ok(preferences) = read_external_sources_config().await else { return; }; @@ -1786,7 +1594,8 @@ impl WorkspaceExternalSourceService { else { return; }; - let ecosystem_id = lock_coordinator(&self.coordinator).ecosystem_for_provider(&provider_id); + let ecosystem_id = + lock_coordinator(&self.control_plane).ecosystem_for_provider(&provider_id); if ecosystem_id.is_none_or(|ecosystem_id| { !integration_capability_is_discoverable( &policy, @@ -1797,28 +1606,15 @@ impl WorkspaceExternalSourceService { self.ensure_watch_roots(&policy).await; return; } - let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_result(result); + let command_snapshot = lock_coordinator(&self.control_plane).apply_discovery_result(result); self.ensure_watch_roots(&policy).await; if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); } } - async fn complete_deferred_tool_discovery( - &self, - provider_id: bitfun_product_domains::external_sources::ProviderId, - result: ExternalToolDiscoveryResult, - ) { - let _refresh_guard = self.refresh_gate.lock().await; - if self - .tool_discovery_tasks - .lock() - .await - .remove(&provider_id) - .is_none() - { - return; - } + async fn complete_deferred_tool_discovery(&self, result: ExternalToolDiscoveryResult) { + let provider_id = result.provider_id().clone(); let Ok(preferences) = read_external_sources_config().await else { return; }; @@ -1827,7 +1623,7 @@ impl WorkspaceExternalSourceService { return; }; let ecosystem_id = - lock_tool_coordinator(&self.tool_coordinator).ecosystem_for_provider(&provider_id); + lock_tool_coordinator(&self.control_plane).ecosystem_for_provider(&provider_id); if ecosystem_id.is_none_or(|ecosystem_id| { !integration_capability_is_discoverable( &policy, @@ -1838,9 +1634,9 @@ impl WorkspaceExternalSourceService { self.ensure_watch_roots(&policy).await; return; } - lock_tool_coordinator(&self.tool_coordinator).apply_discovery_result(result); + lock_tool_coordinator(&self.control_plane).apply_discovery_result(result); self.ensure_watch_roots(&policy).await; - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); } @@ -1848,19 +1644,9 @@ impl WorkspaceExternalSourceService { async fn complete_deferred_subagent_discovery( self: &Arc, - provider_id: bitfun_product_domains::external_sources::ProviderId, result: ExternalSubagentDiscoveryResult, ) { - let _refresh_guard = self.refresh_gate.lock().await; - if self - .subagent_discovery_tasks - .lock() - .await - .remove(&provider_id) - .is_none() - { - return; - } + let provider_id = result.provider_id().clone(); let Ok(preferences) = read_external_sources_config().await else { return; }; @@ -1868,8 +1654,8 @@ impl WorkspaceExternalSourceService { else { return; }; - let ecosystem_id = lock_subagent_coordinator(&self.subagent_coordinator) - .ecosystem_for_provider(&provider_id); + let ecosystem_id = + lock_subagent_coordinator(&self.control_plane).ecosystem_for_provider(&provider_id); if ecosystem_id.is_none_or(|ecosystem_id| { !integration_capability_is_discoverable( &policy, @@ -1881,30 +1667,17 @@ impl WorkspaceExternalSourceService { return; } let subagent_snapshot = - lock_subagent_coordinator(&self.subagent_coordinator).apply_discovery_result(result); + lock_subagent_coordinator(&self.control_plane).apply_discovery_result(result); self.schedule_subagent_last_valid_expiry(&subagent_snapshot); self.ensure_watch_roots(&policy).await; - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); } } - async fn complete_deferred_mcp_discovery( - &self, - provider_id: bitfun_product_domains::external_sources::ProviderId, - result: ExternalMcpDiscoveryResult, - ) { - let _refresh_guard = self.refresh_gate.lock().await; - if self - .mcp_discovery_tasks - .lock() - .await - .remove(&provider_id) - .is_none() - { - return; - } + async fn complete_deferred_mcp_discovery(&self, result: ExternalMcpDiscoveryResult) { + let provider_id = result.provider_id().clone(); let Ok(preferences) = read_external_sources_config().await else { return; }; @@ -1913,7 +1686,7 @@ impl WorkspaceExternalSourceService { return; }; let ecosystem_id = - lock_mcp_coordinator(&self.mcp_coordinator).ecosystem_for_provider(&provider_id); + lock_mcp_coordinator(&self.control_plane).ecosystem_for_provider(&provider_id); if ecosystem_id.is_none_or(|ecosystem_id| { !integration_capability_is_discoverable( &policy, @@ -1924,9 +1697,9 @@ impl WorkspaceExternalSourceService { self.ensure_watch_roots(&policy).await; return; } - lock_mcp_coordinator(&self.mcp_coordinator).apply_discovery_result(result); + lock_mcp_coordinator(&self.control_plane).apply_discovery_result(result); self.ensure_watch_roots(&policy).await; - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); } @@ -1953,8 +1726,8 @@ impl WorkspaceExternalSourceService { return; } let _refresh_guard = service.refresh_gate.lock().await; - lock_subagent_coordinator(&service.subagent_coordinator).expire_last_valid(); - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + lock_subagent_coordinator(&service.control_plane).expire_last_valid(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { let _ = service.updates.send(snapshot); } @@ -2075,10 +1848,10 @@ impl WorkspaceExternalSourceService { } fn source_location(&self, stable_key: &str) -> Result { - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); - let tool_snapshot = lock_tool_coordinator(&self.tool_coordinator).snapshot(); - let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).snapshot(); - let mcp_snapshot = lock_mcp_coordinator(&self.mcp_coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + let tool_snapshot = lock_tool_coordinator(&self.control_plane).snapshot(); + let subagent_snapshot = lock_subagent_coordinator(&self.control_plane).snapshot(); + let mcp_snapshot = lock_mcp_coordinator(&self.control_plane).snapshot(); resolve_external_source_location( stable_key, command_snapshot @@ -2090,6 +1863,172 @@ impl WorkspaceExternalSourceService { ) } + fn surface_snapshot( + &self, + host_capabilities: ExternalSourceHostCapabilities, + ) -> ExternalSourceSurfaceSnapshotV1 { + let catalog = self.snapshot(); + let safe_mode = self.safe_mode_enabled(); + let control = ExternalSourceControlSnapshotV1::from_catalog( + &catalog, + self.execution_domain_id.clone(), + safe_mode, + host_capabilities.clone(), + ); + let mut public = ExternalSourcePublicSnapshot::from(catalog); + public.host_capabilities = host_capabilities; + ExternalSourceSurfaceSnapshotV1 { + control, + catalog: public, + } + } + + fn safe_mode_enabled(&self) -> bool { + external_source_safe_mode_enabled_for( + self.execution_domain_id.as_str(), + &workspace_route_key(self.workspace_root.as_deref()), + ) + } + + fn write_safe_mode(&self, enabled: bool) { + set_external_source_safe_mode_for( + self.execution_domain_id.as_str(), + &workspace_route_key(self.workspace_root.as_deref()), + enabled, + ); + } + + async fn set_safe_mode( + &self, + enabled: bool, + expected_preference_revision: Option, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let previous = self.safe_mode_enabled(); + if let Some(expected_revision) = expected_preference_revision { + let (accepted, _) = ExternalSourcePreferenceStore::global()? + .update(|config| { + if config.preference_revision != expected_revision { + return false; + } + // Safe Mode is process-local, but its CAS linearization + // point must share the authoritative preference file lock + // with every persisted source/review mutation. + self.write_safe_mode(enabled); + true + }) + .await?; + if !accepted { + return Err(stale_operation_error( + "External source preferences changed; refresh before changing Safe Mode", + )); + } + if let Err(error) = sync_service_preferences(self).await { + self.write_safe_mode(previous); + return Err(error); + } + } else { + self.write_safe_mode(enabled); + } + if previous == enabled { + return Ok(self.snapshot()); + } + + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + match self.rebuild_product_snapshot(command_snapshot).await { + Ok(snapshot) => { + let _ = self.updates.send(snapshot.clone()); + Ok(snapshot) + } + Err(error) => { + self.write_safe_mode(previous); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + if let Err(rollback_error) = self.rebuild_product_snapshot(command_snapshot).await { + log::error!( + "External source Safe Mode rollback failed execution_domain={} reason={}", + safe_external_log_token(self.execution_domain_id.as_str()), + safe_external_log_token(&rollback_error), + ); + } + Err(error) + } + } + } + + async fn apply_control_action( + self: &Arc, + request: ExternalSourceControlRequestV1, + ) -> ExternalSourceOperationResult { + use bitfun_product_domains::external_source_control::ExternalSourceOperationStage; + + if let Err(detail) = request.validate() { + return Err(ExternalSourceOperationError::invalid_request(detail) + .with_correlation_id(request.operation_id) + .with_stage(ExternalSourceOperationStage::ValidateRequest)); + } + + let started = std::time::Instant::now(); + let operation_id = request.operation_id; + let expected_revision = request.expected_preference_revision; + let (action, stage, result) = match request.action { + ExternalSourceControlActionV1::Refresh => ( + "refresh", + ExternalSourceOperationStage::Discover, + self.refresh().await, + ), + ExternalSourceControlActionV1::SetSourceEnabled { + source_key, + enabled, + } => { + let stage = ExternalSourceOperationStage::ApplyPreference; + let result = match expected_revision { + Some(revision) => { + self.set_source_enabled(&source_key, enabled, revision) + .await + } + None => Err(invalid_operation_error( + "External source enablement requires an expected preference revision", + )), + }; + ("set_source_enabled", stage, result) + } + ExternalSourceControlActionV1::SetSafeMode { enabled } => { + let stage = ExternalSourceOperationStage::ApplyPreference; + let result = match expected_revision { + Some(revision) => self.set_safe_mode(enabled, Some(revision)).await, + None => Err(invalid_operation_error( + "Safe Mode requires an expected preference revision", + )), + }; + ("set_safe_mode", stage, result) + } + }; + + match result { + Ok(snapshot) => { + log::info!( + "External source control action outcome=success action={} correlation_id={} duration_ms={} generation={}", + action, + safe_external_log_token(&operation_id), + started.elapsed().as_millis(), + snapshot.generation, + ); + Ok(self.surface_snapshot(ExternalSourceHostCapabilities::read_write())) + } + Err(error) => { + let typed = typed_control_operation_error(error, &operation_id, stage); + log::warn!( + "External source control action outcome=failure action={} correlation_id={} duration_ms={} error_code={}", + action, + safe_external_log_token(&operation_id), + started.elapsed().as_millis(), + typed.code.as_str(), + ); + Err(typed) + } + } + } + async fn set_source_enabled( self: &Arc, stable_key: &str, @@ -2103,25 +2042,25 @@ impl WorkspaceExternalSourceService { )); } let (previous_commands, command_known) = { - let mut coordinator = lock_coordinator(&self.coordinator); + let mut coordinator = lock_coordinator(&self.control_plane); let previous = coordinator.suppressed_sources().clone(); let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); (previous, known) }; let (previous_tools, tool_known) = { - let mut coordinator = lock_tool_coordinator(&self.tool_coordinator); + let mut coordinator = lock_tool_coordinator(&self.control_plane); let previous = coordinator.suppressed_sources().clone(); let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); (previous, known) }; let (previous_subagents, subagent_known) = { - let mut coordinator = lock_subagent_coordinator(&self.subagent_coordinator); + let mut coordinator = lock_subagent_coordinator(&self.control_plane); let previous = coordinator.suppressed_sources().clone(); let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); (previous, known) }; let (previous_mcps, mcp_known) = { - let mut coordinator = lock_mcp_coordinator(&self.mcp_coordinator); + let mut coordinator = lock_mcp_coordinator(&self.control_plane); let previous = coordinator.suppressed_sources().clone(); let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); (previous, known) @@ -2137,24 +2076,23 @@ impl WorkspaceExternalSourceService { { Ok(authoritative) => authoritative, Err(error) => { - lock_coordinator(&self.coordinator) + lock_coordinator(&self.control_plane) .replace_suppressed_sources(previous_commands); - lock_tool_coordinator(&self.tool_coordinator) + lock_tool_coordinator(&self.control_plane) .replace_suppressed_sources(previous_tools); - lock_subagent_coordinator(&self.subagent_coordinator) + lock_subagent_coordinator(&self.control_plane) .replace_suppressed_sources(previous_subagents); - lock_mcp_coordinator(&self.mcp_coordinator) + lock_mcp_coordinator(&self.control_plane) .replace_suppressed_sources(previous_mcps); return Err(error); } }; - lock_coordinator(&self.coordinator).replace_suppressed_sources(authoritative.clone()); - lock_tool_coordinator(&self.tool_coordinator) - .replace_suppressed_sources(authoritative.clone()); - lock_subagent_coordinator(&self.subagent_coordinator) + lock_coordinator(&self.control_plane).replace_suppressed_sources(authoritative.clone()); + lock_tool_coordinator(&self.control_plane) .replace_suppressed_sources(authoritative.clone()); - lock_mcp_coordinator(&self.mcp_coordinator) + lock_subagent_coordinator(&self.control_plane) .replace_suppressed_sources(authoritative.clone()); + lock_mcp_coordinator(&self.control_plane).replace_suppressed_sources(authoritative.clone()); propagate_suppressed_sources(&authoritative, self); // Refresh acquires the same gate. Release the mutation critical section // after the preference and in-memory coordinators agree, then refresh @@ -2211,7 +2149,7 @@ impl WorkspaceExternalSourceService { )); } let (previous_choices, previous_lineage_keys, previous_conflicted_ids, participants) = { - let mut coordinator = lock_coordinator(&self.coordinator); + let mut coordinator = lock_coordinator(&self.control_plane); let participants = coordinator .snapshot() .command_conflicts @@ -2241,7 +2179,7 @@ impl WorkspaceExternalSourceService { ) }; let (updated_choices, updated_lineage_keys, updated_conflicted_ids) = { - let coordinator = lock_coordinator(&self.coordinator); + let coordinator = lock_coordinator(&self.control_plane); ( coordinator.conflict_choices().clone(), coordinator.conflict_lineage_current_keys().clone(), @@ -2258,7 +2196,7 @@ impl WorkspaceExternalSourceService { { Ok(authoritative) => authoritative, Err(error) => { - let mut coordinator = lock_coordinator(&self.coordinator); + let mut coordinator = lock_coordinator(&self.control_plane); coordinator.replace_conflict_choices(previous_choices); coordinator.replace_conflict_lineage_current_keys(previous_lineage_keys); coordinator.replace_conflicted_candidate_ids(previous_conflicted_ids); @@ -2272,7 +2210,7 @@ impl WorkspaceExternalSourceService { log::debug!("External source conflict preferences changed in another workspace"); } propagate_conflict_preferences(&authoritative); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); self.rebuild_product_snapshot(command_snapshot).await } @@ -2329,7 +2267,7 @@ impl WorkspaceExternalSourceService { ) .await?; propagate_tool_preferences(&preferences); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); self.rebuild_product_snapshot(command_snapshot).await } @@ -2372,7 +2310,7 @@ impl WorkspaceExternalSourceService { persist_tool_conflict_choice(conflict_key, candidate_id, expected_preference_revision) .await?; propagate_tool_preferences(&preferences); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); self.rebuild_product_snapshot(command_snapshot).await } @@ -2418,7 +2356,7 @@ impl WorkspaceExternalSourceService { persist_mcp_server_decision(decision_key, approved, expected_preference_revision) .await?; propagate_mcp_preferences(&preferences); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); self.rebuild_product_snapshot(command_snapshot).await } @@ -2484,7 +2422,7 @@ impl WorkspaceExternalSourceService { ) .await?; propagate_mcp_preferences(&preferences); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); self.rebuild_product_snapshot(command_snapshot).await } @@ -2537,7 +2475,7 @@ impl WorkspaceExternalSourceService { persist_subagent_activation(decision_key, approved, expected_preference_revision) .await?; propagate_subagent_preferences(&preferences); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); self.rebuild_product_snapshot(command_snapshot).await } @@ -2611,7 +2549,7 @@ impl WorkspaceExternalSourceService { ) .await?; propagate_subagent_preferences(&preferences); - let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); self.rebuild_product_snapshot(command_snapshot).await } @@ -2634,7 +2572,7 @@ impl WorkspaceExternalSourceService { missing_candidate_error(format!("External prompt command '{name}' was not found")) })?; ensure_source_capability_active(&snapshot, &source_key, EXTERNAL_CAPABILITY_COMMAND)?; - let coordinator = Arc::clone(&self.coordinator); + let coordinator = Arc::clone(&self.control_plane); let name = name.to_string(); let arguments = arguments.to_string(); let expected_candidate_id = expected_candidate_id.map(str::to_string); @@ -2716,7 +2654,7 @@ impl WorkspaceExternalSourceService { let Some(service) = weak.upgrade() else { break; }; - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); match service.rebuild_product_snapshot(command_snapshot).await { Ok(snapshot) => { let _ = service.updates.send(snapshot); @@ -2800,24 +2738,23 @@ impl WorkspaceExternalSourceService { let command_ecosystems = ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_COMMAND); provider_roots.extend( - lock_coordinator(&self.coordinator).watch_roots_for_ecosystems(&command_ecosystems), + lock_coordinator(&self.control_plane).watch_roots_for_ecosystems(&command_ecosystems), ); let tool_ecosystems = ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_TOOL); provider_roots.extend( - lock_tool_coordinator(&self.tool_coordinator) - .watch_roots_for_ecosystems(&tool_ecosystems), + lock_tool_coordinator(&self.control_plane).watch_roots_for_ecosystems(&tool_ecosystems), ); let subagent_ecosystems = ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_SUBAGENT); provider_roots.extend( - lock_subagent_coordinator(&self.subagent_coordinator) + lock_subagent_coordinator(&self.control_plane) .watch_roots_for_ecosystems(&subagent_ecosystems), ); let mcp_ecosystems = ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_MCP); provider_roots.extend( - lock_mcp_coordinator(&self.mcp_coordinator).watch_roots_for_ecosystems(&mcp_ecosystems), + lock_mcp_coordinator(&self.control_plane).watch_roots_for_ecosystems(&mcp_ecosystems), ); for root in provider_roots { roots @@ -2842,293 +2779,28 @@ impl WorkspaceExternalSourceService { } } -enum DiscoveryPoll { - Complete(ExternalSourceDiscoveryResult), - InFlight(bitfun_product_domains::external_sources::ProviderId), - TimedOut(bitfun_product_domains::external_sources::ProviderId), -} - -enum ToolDiscoveryPoll { - Complete(ExternalToolDiscoveryResult), - InFlight(bitfun_product_domains::external_sources::ProviderId), - TimedOut(bitfun_product_domains::external_sources::ProviderId), -} - -enum SubagentDiscoveryPoll { - Complete(ExternalSubagentDiscoveryResult), - InFlight(bitfun_product_domains::external_sources::ProviderId), - TimedOut(bitfun_product_domains::external_sources::ProviderId), -} - -enum McpDiscoveryPoll { - Complete(ExternalMcpDiscoveryResult), - InFlight(bitfun_product_domains::external_sources::ProviderId), - TimedOut(bitfun_product_domains::external_sources::ProviderId), -} - -async fn poll_discovery_tasks( - scheduled: Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedDiscoveryTask, - bool, - )>, - timeout: std::time::Duration, -) -> Vec { - join_all( - scheduled - .into_iter() - .map(|(provider_id, task, is_new)| async move { - if !is_new { - return match task.clone().now_or_never() { - Some(result) => DiscoveryPoll::Complete(result), - None => DiscoveryPoll::InFlight(provider_id), - }; - } - match tokio::time::timeout(timeout, task).await { - Ok(result) => DiscoveryPoll::Complete(result), - Err(_) => DiscoveryPoll::TimedOut(provider_id), - } - }), - ) - .await -} - -async fn poll_tool_discovery_tasks( - scheduled: Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedToolDiscoveryTask, - bool, - )>, - timeout: std::time::Duration, -) -> Vec { - join_all( - scheduled - .into_iter() - .map(|(provider_id, task, is_new)| async move { - if !is_new { - return match task.clone().now_or_never() { - Some(result) => ToolDiscoveryPoll::Complete(result), - None => ToolDiscoveryPoll::InFlight(provider_id), - }; - } - match tokio::time::timeout(timeout, task).await { - Ok(result) => ToolDiscoveryPoll::Complete(result), - Err(_) => ToolDiscoveryPoll::TimedOut(provider_id), - } - }), - ) - .await -} - -async fn poll_subagent_discovery_tasks( - scheduled: Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedSubagentDiscoveryTask, - bool, - )>, - timeout: std::time::Duration, -) -> Vec { - join_all( - scheduled - .into_iter() - .map(|(provider_id, task, is_new)| async move { - if !is_new { - return match task.clone().now_or_never() { - Some(result) => SubagentDiscoveryPoll::Complete(result), - None => SubagentDiscoveryPoll::InFlight(provider_id), - }; - } - match tokio::time::timeout(timeout, task).await { - Ok(result) => SubagentDiscoveryPoll::Complete(result), - Err(_) => SubagentDiscoveryPoll::TimedOut(provider_id), - } - }), - ) - .await -} - -async fn poll_mcp_discovery_tasks( - scheduled: Vec<( - bitfun_product_domains::external_sources::ProviderId, - SharedMcpDiscoveryTask, - bool, - )>, - timeout: std::time::Duration, -) -> Vec { - join_all( - scheduled - .into_iter() - .map(|(provider_id, task, is_new)| async move { - if !is_new { - return match task.clone().now_or_never() { - Some(result) => McpDiscoveryPoll::Complete(result), - None => McpDiscoveryPoll::InFlight(provider_id), - }; - } - match tokio::time::timeout(timeout, task).await { - Ok(result) => McpDiscoveryPoll::Complete(result), - Err(_) => McpDiscoveryPoll::TimedOut(provider_id), - } - }), - ) - .await -} - -fn spawn_discovery_task(request: ExternalSourceDiscoveryRequest) -> SharedDiscoveryTask { - let provider_id = request.provider_id().clone(); - async move { - match tokio::task::spawn_blocking(move || request.execute()).await { - Ok(result) => result, - Err(error) => discovery_failure( - provider_id, - "external_source.discovery_task_failed", - &format!("provider discovery task failed: {error}"), - ), - } - } - .boxed() - .shared() -} - -fn spawn_tool_discovery_task(request: ExternalToolDiscoveryRequest) -> SharedToolDiscoveryTask { - let provider_id = request.provider_id().clone(); - async move { - match tokio::task::spawn_blocking(move || request.execute()).await { - Ok(result) => result, - Err(error) => tool_discovery_failure( - provider_id, - "external_tool.discovery_task_failed", - &format!("tool provider discovery task failed: {error}"), - ), - } - } - .boxed() - .shared() -} - -fn spawn_subagent_discovery_task( - request: ExternalSubagentDiscoveryRequest, -) -> SharedSubagentDiscoveryTask { - let provider_id = request.provider_id().clone(); - async move { - match tokio::task::spawn_blocking(move || request.execute()).await { - Ok(result) => result, - Err(error) => subagent_discovery_failure( - provider_id, - "external_subagent.discovery_task_failed", - &format!("subagent provider discovery task failed: {error}"), - ), - } - } - .boxed() - .shared() -} - -fn spawn_mcp_discovery_task(request: ExternalMcpDiscoveryRequest) -> SharedMcpDiscoveryTask { - let provider_id = request.provider_id().clone(); - async move { - match tokio::task::spawn_blocking(move || request.execute()).await { - Ok(result) => result, - Err(error) => mcp_discovery_failure( - provider_id, - "external_mcp.discovery_task_failed", - &format!("MCP provider discovery task failed: {error}"), - ), - } - } - .boxed() - .shared() -} - -fn discovery_failure( - provider_id: bitfun_product_domains::external_sources::ProviderId, - code: &str, - message: &str, -) -> ExternalSourceDiscoveryResult { - ExternalSourceDiscoveryResult::failed( - provider_id, - bitfun_product_domains::external_sources::ExternalSourceProviderError::new( - code, message, true, - ), - ) -} - -fn tool_discovery_failure( - provider_id: bitfun_product_domains::external_sources::ProviderId, - code: &str, - message: &str, -) -> ExternalToolDiscoveryResult { - ExternalToolDiscoveryResult::failed( - provider_id, - bitfun_product_domains::external_sources::ExternalSourceProviderError::new( - code, message, true, - ), - ) -} - -fn subagent_discovery_failure( - provider_id: bitfun_product_domains::external_sources::ProviderId, - code: &str, - message: &str, -) -> ExternalSubagentDiscoveryResult { - ExternalSubagentDiscoveryResult::failed( - provider_id, - bitfun_product_domains::external_sources::ExternalSourceProviderError::new( - code, message, true, - ), - ) -} - -fn mcp_discovery_failure( - provider_id: bitfun_product_domains::external_sources::ProviderId, - code: &str, - message: &str, -) -> ExternalMcpDiscoveryResult { - ExternalMcpDiscoveryResult::failed( - provider_id, - bitfun_product_domains::external_sources::ExternalSourceProviderError::new( - code, message, true, - ), - ) -} - fn lock_coordinator( - coordinator: &StdMutex, -) -> MutexGuard<'_, ExternalSourceCoordinator> { - match coordinator.lock() { - Ok(guard) => guard, - Err(poisoned) => { - log::error!("External source coordinator mutex was poisoned, recovering lock"); - poisoned.into_inner() - } - } + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalSourceCoordinator> { + control_plane.lock_commands() } fn lock_tool_coordinator( - coordinator: &StdMutex, -) -> MutexGuard<'_, ExternalToolCoordinator> { - match coordinator.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalToolCoordinator> { + control_plane.lock_tools() } fn lock_subagent_coordinator( - coordinator: &StdMutex, -) -> MutexGuard<'_, ExternalSubagentCoordinator> { - match coordinator.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalSubagentCoordinator> { + control_plane.lock_subagents() } fn lock_mcp_coordinator( - coordinator: &StdMutex, -) -> MutexGuard<'_, ExternalMcpCoordinator> { - match coordinator.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalMcpCoordinator> { + control_plane.lock_mcp() } fn lock_snapshot( @@ -3146,6 +2818,11 @@ static WORKSPACE_SERVICES: OnceLock< static READ_ONLY_WORKSPACE_SERVICES: OnceLock< DashMap, Weak>, > = OnceLock::new(); +static SAFE_MODE_WORKSPACES: OnceLock> = OnceLock::new(); + +fn safe_mode_workspaces() -> &'static DashMap { + SAFE_MODE_WORKSPACES.get_or_init(DashMap::new) +} static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); @@ -3680,6 +3357,9 @@ pub(crate) async fn external_tool_invocation_is_authorized( source_key: &str, workspace_route: &str, ) -> Result { + if external_source_safe_mode_enabled_for(LEGACY_LOCAL_EXECUTION_DOMAIN_ID, workspace_route) { + return Ok(false); + } let preferences = read_external_sources_config().await?; Ok(external_tool_invocation_is_authorized_by( &preferences, @@ -4486,12 +4166,11 @@ fn propagate_suppressed_sources( if Arc::ptr_eq(&service, current) { continue; } - lock_coordinator(&service.coordinator).replace_suppressed_sources(sources.clone()); - lock_tool_coordinator(&service.tool_coordinator) - .replace_suppressed_sources(sources.clone()); - lock_subagent_coordinator(&service.subagent_coordinator) + lock_coordinator(&service.control_plane).replace_suppressed_sources(sources.clone()); + lock_tool_coordinator(&service.control_plane).replace_suppressed_sources(sources.clone()); + lock_subagent_coordinator(&service.control_plane) .replace_suppressed_sources(sources.clone()); - lock_mcp_coordinator(&service.mcp_coordinator).replace_suppressed_sources(sources.clone()); + lock_mcp_coordinator(&service.control_plane).replace_suppressed_sources(sources.clone()); tokio::spawn(async move { if let Err(error) = service.refresh_preserving_worker_recovery().await { log::warn!( @@ -4510,7 +4189,7 @@ fn propagate_conflict_preferences(preferences: &ExternalSourcesConfig) { continue; }; { - let mut coordinator = lock_coordinator(&service.coordinator); + let mut coordinator = lock_coordinator(&service.control_plane); coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); coordinator.replace_conflict_lineage_current_keys( preferences.conflict_lineage_current_keys.clone(), @@ -4519,7 +4198,7 @@ fn propagate_conflict_preferences(preferences: &ExternalSourcesConfig) { .replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids.clone()); } tokio::spawn(async move { - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { let _ = service.updates.send(snapshot); } @@ -4533,7 +4212,7 @@ fn propagate_tool_preferences(_preferences: &ExternalSourcesConfig) { continue; }; tokio::spawn(async move { - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { let _ = service.updates.send(snapshot); } @@ -4547,7 +4226,7 @@ fn propagate_subagent_preferences(_preferences: &ExternalSourcesConfig) { continue; }; tokio::spawn(async move { - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { let _ = service.updates.send(snapshot); } @@ -4561,7 +4240,7 @@ fn propagate_mcp_preferences(_preferences: &ExternalSourcesConfig) { continue; }; tokio::spawn(async move { - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { let _ = service.updates.send(snapshot); } @@ -4609,7 +4288,7 @@ pub(crate) fn notify_external_tool_registry_changed() { .filter_map(|entry| entry.value().upgrade()) .collect::>(); for service in services { - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { let _ = service.updates.send(snapshot); } @@ -4637,7 +4316,7 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R .cloned() .collect::>(); let command_changed = { - let mut coordinator = lock_coordinator(&service.coordinator); + let mut coordinator = lock_coordinator(&service.control_plane); let mut changed = false; if coordinator.suppressed_sources() != &suppressed_sources { coordinator.replace_suppressed_sources(suppressed_sources.clone()); @@ -4662,7 +4341,7 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R changed }; let tool_changed = { - let mut coordinator = lock_tool_coordinator(&service.tool_coordinator); + let mut coordinator = lock_tool_coordinator(&service.control_plane); if coordinator.suppressed_sources() != &suppressed_sources { coordinator.replace_suppressed_sources(suppressed_sources.clone()); true @@ -4671,7 +4350,7 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R } }; let subagent_changed = { - let mut coordinator = lock_subagent_coordinator(&service.subagent_coordinator); + let mut coordinator = lock_subagent_coordinator(&service.control_plane); if coordinator.suppressed_sources() != &suppressed_sources { coordinator.replace_suppressed_sources(suppressed_sources.clone()); true @@ -4680,7 +4359,7 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R } }; let mcp_changed = { - let mut coordinator = lock_mcp_coordinator(&service.mcp_coordinator); + let mut coordinator = lock_mcp_coordinator(&service.control_plane); if coordinator.suppressed_sources() != &suppressed_sources { coordinator.replace_suppressed_sources(suppressed_sources.clone()); true @@ -4698,7 +4377,7 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R || subagent_preferences_changed || policy_changed { - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); let snapshot = service.rebuild_product_snapshot(command_snapshot).await?; let _ = service.updates.send(snapshot); } @@ -4966,6 +4645,50 @@ pub async fn external_source_location_for_host_action( .source_location(stable_key) } +pub async fn get_external_source_control_snapshot( + workspace_root: Option<&Path>, + force_refresh: bool, + host_capabilities: ExternalSourceHostCapabilities, +) -> ExternalSourceOperationResult { + use bitfun_product_domains::external_source_control::ExternalSourceOperationStage; + + let service = if host_capabilities.can_execute_external_assets { + service_for(workspace_root).await + } else { + read_only_service_for(workspace_root).await + } + .map_err(|error| { + sanitize_external_source_operation_error(error) + .with_stage(ExternalSourceOperationStage::ProjectResponse) + })?; + if force_refresh { + service.refresh().await.map_err(|error| { + sanitize_external_source_operation_error(error) + .with_stage(ExternalSourceOperationStage::Discover) + })?; + } else { + service.ensure_background_refresh(); + } + Ok(service.surface_snapshot(host_capabilities)) +} + +pub async fn apply_external_source_control_action( + workspace_root: Option<&Path>, + request: ExternalSourceControlRequestV1, +) -> ExternalSourceOperationResult { + use bitfun_product_domains::external_source_control::ExternalSourceOperationStage; + + let operation_id = request.operation_id.clone(); + let service = service_for(workspace_root).await.map_err(|error| { + typed_control_operation_error( + error, + &operation_id, + ExternalSourceOperationStage::ValidateRequest, + ) + })?; + service.apply_control_action(request).await +} + /// Returns a static, sanitized projection for Hosts that may inspect external /// configuration but must never load external code or alter runtime routes. pub async fn external_source_read_only_snapshot( @@ -5090,7 +4813,7 @@ fn external_log_error_category(error: &str) -> String { /// id, while the local log retains only a bounded category token. pub fn sanitize_external_source_operation_error(error: String) -> ExternalSourceOperationError { if let Some(typed) = ExternalSourceOperationError::decode(&error) { - return typed; + return typed.with_default_recovery_actions(); } static NEXT_CORRELATION_ID: AtomicU64 = AtomicU64::new(1); let correlation_id = format!( @@ -5109,6 +4832,24 @@ pub fn sanitize_external_source_operation_error(error: String) -> ExternalSource true, ) .with_correlation_id(correlation_id) + .with_default_recovery_actions() +} + +fn typed_control_operation_error( + error: String, + operation_id: &str, + stage: bitfun_product_domains::external_source_control::ExternalSourceOperationStage, +) -> ExternalSourceOperationError { + let mut typed = sanitize_external_source_operation_error(error); + if typed.correlation_id.is_none() { + typed = typed.with_correlation_id(operation_id); + } else if typed.causation_id.is_none() { + typed = typed.with_causation_id(operation_id); + } + if typed.stage.is_none() { + typed = typed.with_stage(stage); + } + typed } fn encoded_operation_error( @@ -5589,14 +5330,13 @@ mod tests { execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), }; let (updates, _) = broadcast::channel(8); - let coordinator = ExternalSourceCoordinator::new(context.clone(), providers).unwrap(); - let tool_coordinator = ExternalToolCoordinator::new(context.clone(), Vec::new()).unwrap(); - let subagent_coordinator = - ExternalSubagentCoordinator::new(context.clone(), Vec::new()).unwrap(); - let mcp_coordinator = ExternalMcpCoordinator::new(context, Vec::new()).unwrap(); + let control_plane = Arc::new( + ExternalSourceControlPlane::new(context, providers, Vec::new(), Vec::new(), Vec::new()) + .unwrap(), + ); let mut snapshot = merge_tool_state( - coordinator.snapshot(), - &tool_coordinator.snapshot(), + control_plane.commands(|coordinator| coordinator.snapshot()), + &control_plane.tools(|coordinator| coordinator.snapshot()), ExternalToolProductState::default(), ); snapshot.integration_policy = @@ -5606,19 +5346,12 @@ mod tests { profile: ExternalSourceServiceProfile::LocalExecution, workspace_root: None, execution_domain_id: ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID).unwrap(), - coordinator: Arc::new(StdMutex::new(coordinator)), - tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), - subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), - mcp_coordinator: Arc::new(StdMutex::new(mcp_coordinator)), + control_plane, snapshot: StdMutex::new(snapshot), updates, watch_states: tokio::sync::Mutex::new(BTreeMap::new()), refresh_gate: tokio::sync::Mutex::new(()), product_rebuild_gate: tokio::sync::Mutex::new(()), - discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), - tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), - subagent_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), - mcp_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), mcp_runtime: Arc::new(BitFunExternalMcpRuntime), active_mcp_runtime_ids: tokio::sync::Mutex::new(BTreeSet::new()), initial_refresh_completed: AtomicBool::new(false), @@ -5633,6 +5366,136 @@ mod tests { }) } + #[test] + fn surface_snapshot_projects_control_and_legacy_catalog_from_one_generation() { + let service = test_service(Vec::new()); + + let surface = service.surface_snapshot(ExternalSourceHostCapabilities::read_write()); + + assert_eq!( + surface.control.refresh_generation, + surface.catalog.generation + ); + assert_eq!(surface.control.capabilities.len(), 4); + assert!(!surface.control.safe_mode); + } + + #[tokio::test] + async fn safe_mode_keeps_commands_visible_and_retires_managed_executables() { + let discovery_calls = Arc::new(AtomicUsize::new(0)); + let runtime = Arc::new(CountingExternalMcpRuntime::default()); + let workspace = tempfile::tempdir().unwrap(); + let mut service = test_service(vec![delayed_provider( + "opencode", + std::time::Duration::ZERO, + discovery_calls, + )]); + let service_inner = Arc::get_mut(&mut service).expect("test owns the service"); + service_inner.workspace_root = Some(workspace.path().to_path_buf()); + service_inner.mcp_runtime = runtime.clone(); + + let requests = lock_coordinator(&service.control_plane).discovery_requests(); + let batch = service + .control_plane + .discover_commands(requests, std::time::Duration::from_secs(1)) + .await; + assert!(batch.deferred.is_empty()); + let mut catalog = + lock_coordinator(&service.control_plane).apply_discovery_results(batch.immediate); + catalog.integration_policy = service.snapshot().integration_policy; + *lock_snapshot(&service.snapshot) = catalog; + service + .active_mcp_runtime_ids + .lock() + .await + .insert("managed-mcp".to_string()); + + let snapshot = service.set_safe_mode(true, None).await.unwrap(); + let surface = service.surface_snapshot(ExternalSourceHostCapabilities::read_write()); + + assert!(surface.control.safe_mode); + assert!(!surface.catalog.commands.is_empty()); + assert!(snapshot + .tools + .iter() + .all(|tool| !matches!(tool.activation, ExternalToolActivationState::Active))); + assert!(snapshot + .mcp_servers + .iter() + .all(|server| server.runtime_id.is_none())); + assert!(snapshot.subagents.iter().all(|agent| !matches!( + agent.activation_state, + ExternalSubagentActivationState::Active + ))); + assert!(service.active_mcp_runtime_ids.lock().await.is_empty()); + assert!(runtime.calls.load(Ordering::SeqCst) >= 2); + + service.set_safe_mode(false, None).await.unwrap(); + assert!( + !service + .surface_snapshot(ExternalSourceHostCapabilities::read_write()) + .control + .safe_mode + ); + } + + #[tokio::test] + async fn control_action_returns_typed_stale_revision_and_shared_surface() { + let workspace = tempfile::tempdir().unwrap(); + let mut service = test_service(Vec::new()); + Arc::get_mut(&mut service) + .expect("test owns the service") + .workspace_root = Some(workspace.path().to_path_buf()); + let revision = read_external_sources_config() + .await + .unwrap() + .preference_revision; + lock_snapshot(&service.snapshot).preference_revision = revision; + let stale_request = ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: "safe-mode-stale".to_string(), + expected_preference_revision: Some(revision.saturating_add(1)), + action: ExternalSourceControlActionV1::SetSafeMode { enabled: true }, + }; + + let error = service + .apply_control_action(stale_request) + .await + .unwrap_err(); + + assert_eq!(error.code, ExternalSourceOperationErrorCode::StaleRevision); + assert_eq!(error.correlation_id.as_deref(), Some("safe-mode-stale")); + assert_eq!( + error.recovery_actions, + vec![ + bitfun_product_domains::external_source_control::ExternalSourceRecoveryActionV1::Refresh, + ] + ); + assert_eq!( + error.stage, + Some( + bitfun_product_domains::external_source_control::ExternalSourceOperationStage::ApplyPreference + ) + ); + + let response = service + .apply_control_action(ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: "safe-mode-on".to_string(), + expected_preference_revision: Some(revision), + action: ExternalSourceControlActionV1::SetSafeMode { enabled: true }, + }) + .await + .unwrap(); + assert!(response.control.safe_mode); + assert_eq!( + response.control.refresh_generation, + response.catalog.generation + ); + + service.set_safe_mode(false, None).await.unwrap(); + } + #[derive(Default)] struct CountingExternalMcpRuntime { calls: AtomicUsize, @@ -5681,7 +5544,7 @@ mod tests { let preferences = ExternalSourcesConfig::default(); let policy = integration_policy_snapshot(&preferences, None).unwrap(); - let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let command_snapshot = lock_coordinator(&service.control_plane).snapshot(); let snapshot = service .rebuild_read_only_projection(command_snapshot, preferences, policy) .await @@ -6446,24 +6309,28 @@ mod tests { ), ]); - let requests = lock_coordinator(&service.coordinator).discovery_requests(); - let scheduled = service.prepare_discovery_tasks(requests).await; - let polled = poll_discovery_tasks(scheduled, std::time::Duration::from_millis(25)).await; - let results = service.finish_discovery_poll(polled).await; - let snapshot = lock_coordinator(&service.coordinator).apply_discovery_results(results); + let requests = lock_coordinator(&service.control_plane).discovery_requests(); + let batch = service + .control_plane + .discover_commands(requests, std::time::Duration::from_millis(25)) + .await; + let snapshot = + lock_coordinator(&service.control_plane).apply_discovery_results(batch.immediate); + for deferred in batch.deferred { + service.schedule_deferred_command_discovery(deferred); + } assert!(snapshot .commands .iter() .any(|command| command.definition.name == "healthy")); - let requests = lock_coordinator(&service.coordinator).discovery_requests(); - let scheduled = service.prepare_discovery_tasks(requests).await; - assert!(scheduled - .iter() - .any(|(provider_id, _, is_new)| { provider_id.as_str() == "slow" && !is_new })); - let polled = poll_discovery_tasks(scheduled, std::time::Duration::from_millis(25)).await; - let results = service.finish_discovery_poll(polled).await; - let snapshot = lock_coordinator(&service.coordinator).apply_discovery_results(results); + let requests = lock_coordinator(&service.control_plane).discovery_requests(); + let batch = service + .control_plane + .discover_commands(requests, std::time::Duration::from_millis(25)) + .await; + let snapshot = + lock_coordinator(&service.control_plane).apply_discovery_results(batch.immediate); assert_eq!(slow_calls.load(Ordering::SeqCst), 1); assert!(healthy_calls.load(Ordering::SeqCst) >= 2); diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index 5391e0b268..e14e34d0c4 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -6,7 +6,7 @@ use crate::agentic::tools::framework::{ use crate::agentic::tools::registry::get_global_tool_registry; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use bitfun_external_sources::{ExternalToolCoordinator, ExternalToolCoordinatorSnapshot}; +use bitfun_external_sources::{ExternalSourceControlPlane, ExternalToolCoordinatorSnapshot}; use bitfun_product_domains::external_sources::{ external_tool_approval_key, external_tool_conflict_key, external_tool_decision_key, EcosystemId, ExternalSourceAssetKind, ExternalSourceDiagnostic, @@ -98,7 +98,7 @@ pub(super) fn project_external_tools_read_only( .get(&approval_key) .is_some_and(|declined| declined == &decision_key) { - ExternalToolActivationState::Disabled + ExternalToolActivationState::Declined } else { state.approval_requests.push(ExternalToolApprovalRequest { approval_key: approval_key.clone(), @@ -1294,15 +1294,12 @@ pub(super) async fn release_external_tool_workspace(workspace_root: Option<&Path pub(super) async fn reconcile_external_tools( workspace_root: Option<&Path>, execution_domain_id: &str, - coordinator: &Arc>, + control_plane: &Arc, decisions: ExternalToolDecisions<'_>, worker_recovery_targets: &BTreeSet, ) -> ExternalToolProductState { let workspace_key = workspace_route_key(workspace_root); - let snapshot = coordinator - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .snapshot(); + let snapshot = control_plane.tools(|coordinator| coordinator.snapshot()); let mut state = ExternalToolProductState::default(); let source_by_key = snapshot .sources @@ -1425,7 +1422,7 @@ pub(super) async fn reconcile_external_tools( .get(&approval_key) .is_some_and(|declined| declined == &decision_key) { - ExternalToolActivationState::Disabled + ExternalToolActivationState::Declined } else { let source = source_by_key.get(&target_id.source); state.approval_requests.push(ExternalToolApprovalRequest { @@ -1610,14 +1607,13 @@ pub(super) async fn reconcile_external_tools( continue; } - let preparation_coordinator = Arc::clone(coordinator); + let preparation_control_plane = Arc::clone(control_plane); let preparation_target = target_id.clone(); let preparation_revision = first.content_version.clone(); let prepared = tokio::task::spawn_blocking(move || { - preparation_coordinator - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .prepare_target_guarded(&preparation_target, &preparation_revision) + preparation_control_plane.tools_mut(|coordinator| { + coordinator.prepare_target_guarded(&preparation_target, &preparation_revision) + }) }) .await .map_err(|error| format!("tool preparation task failed: {error}")) diff --git a/src/crates/assembly/external-sources/Cargo.toml b/src/crates/assembly/external-sources/Cargo.toml index b8b6a82284..8debb8fa32 100644 --- a/src/crates/assembly/external-sources/Cargo.toml +++ b/src/crates/assembly/external-sources/Cargo.toml @@ -11,6 +11,9 @@ crate-type = ["rlib"] [dependencies] bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources"] } +futures = { workspace = true } +log = { workspace = true } +tokio = { workspace = true } [lints] workspace = true diff --git a/src/crates/assembly/external-sources/src/control_plane.rs b/src/crates/assembly/external-sources/src/control_plane.rs new file mode 100644 index 0000000000..449ebc4754 --- /dev/null +++ b/src/crates/assembly/external-sources/src/control_plane.rs @@ -0,0 +1,287 @@ +//! Unified owner for typed external-source coordinators and discovery lanes. + +use crate::refresh::CompletedDeferredDiscovery; +use crate::{ + DeferredDiscovery, DiscoveryBatch, DiscoveryLane, ExternalMcpCoordinator, + ExternalMcpDiscoveryRequest, ExternalMcpDiscoveryResult, ExternalSourceCoordinator, + ExternalSourceDiscoveryRequest, ExternalSourceDiscoveryResult, ExternalSubagentCoordinator, + ExternalSubagentDiscoveryRequest, ExternalSubagentDiscoveryResult, ExternalToolCoordinator, + ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, +}; +use bitfun_product_domains::external_sources::{ + ExternalMcpSourceProvider, ExternalSourceContext, ExternalToolSourceProvider, + PromptCommandSourceProvider, +}; +use bitfun_product_domains::external_subagents::ExternalSubagentSourceProvider; +use std::collections::BTreeSet; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +pub struct ExternalSourceControlPlane { + commands: Mutex, + tools: Mutex, + subagents: Mutex, + mcp: Mutex, + command_lane: DiscoveryLane, + tool_lane: DiscoveryLane, + subagent_lane: DiscoveryLane, + mcp_lane: DiscoveryLane, +} + +impl fmt::Debug for ExternalSourceControlPlane { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalSourceControlPlane") + .field( + "commands", + &self.commands(|coordinator| format!("{coordinator:?}")), + ) + .field( + "tools", + &self.tools(|coordinator| format!("{coordinator:?}")), + ) + .field( + "subagents", + &self.subagents(|coordinator| format!("{coordinator:?}")), + ) + .field("mcp", &self.mcp(|coordinator| format!("{coordinator:?}"))) + .finish_non_exhaustive() + } +} + +impl ExternalSourceControlPlane { + pub fn new( + context: ExternalSourceContext, + command_providers: Vec>, + tool_providers: Vec>, + subagent_providers: Vec>, + mcp_providers: Vec>, + ) -> Result { + Ok(Self { + commands: Mutex::new(ExternalSourceCoordinator::new( + context.clone(), + command_providers, + )?), + tools: Mutex::new(ExternalToolCoordinator::new( + context.clone(), + tool_providers, + )?), + subagents: Mutex::new(ExternalSubagentCoordinator::new( + context.clone(), + subagent_providers, + )?), + mcp: Mutex::new(ExternalMcpCoordinator::new(context, mcp_providers)?), + command_lane: DiscoveryLane::new(), + tool_lane: DiscoveryLane::new(), + subagent_lane: DiscoveryLane::new(), + mcp_lane: DiscoveryLane::new(), + }) + } + + pub fn commands(&self, read: impl FnOnce(&ExternalSourceCoordinator) -> T) -> T { + read(&lock(&self.commands, "command")) + } + + /// Returns the command coordinator guard for assembly consumers that need + /// to perform an atomic multi-step update. Prefer [`Self::commands`] or + /// [`Self::commands_mut`] for ordinary reads and writes. + #[doc(hidden)] + pub fn lock_commands(&self) -> MutexGuard<'_, ExternalSourceCoordinator> { + lock(&self.commands, "command") + } + + pub fn commands_mut(&self, update: impl FnOnce(&mut ExternalSourceCoordinator) -> T) -> T { + update(&mut lock(&self.commands, "command")) + } + + pub fn tools(&self, read: impl FnOnce(&ExternalToolCoordinator) -> T) -> T { + read(&lock(&self.tools, "tool")) + } + + #[doc(hidden)] + pub fn lock_tools(&self) -> MutexGuard<'_, ExternalToolCoordinator> { + lock(&self.tools, "tool") + } + + pub fn tools_mut(&self, update: impl FnOnce(&mut ExternalToolCoordinator) -> T) -> T { + update(&mut lock(&self.tools, "tool")) + } + + pub fn subagents(&self, read: impl FnOnce(&ExternalSubagentCoordinator) -> T) -> T { + read(&lock(&self.subagents, "subagent")) + } + + #[doc(hidden)] + pub fn lock_subagents(&self) -> MutexGuard<'_, ExternalSubagentCoordinator> { + lock(&self.subagents, "subagent") + } + + pub fn subagents_mut( + &self, + update: impl FnOnce(&mut ExternalSubagentCoordinator) -> T, + ) -> T { + update(&mut lock(&self.subagents, "subagent")) + } + + pub fn mcp(&self, read: impl FnOnce(&ExternalMcpCoordinator) -> T) -> T { + read(&lock(&self.mcp, "MCP")) + } + + #[doc(hidden)] + pub fn lock_mcp(&self) -> MutexGuard<'_, ExternalMcpCoordinator> { + lock(&self.mcp, "MCP") + } + + pub fn mcp_mut(&self, update: impl FnOnce(&mut ExternalMcpCoordinator) -> T) -> T { + update(&mut lock(&self.mcp, "MCP")) + } + + pub fn replace_suppressed_sources(&self, sources: BTreeSet) { + self.commands_mut(|coordinator| coordinator.replace_suppressed_sources(sources.clone())); + self.tools_mut(|coordinator| coordinator.replace_suppressed_sources(sources.clone())); + self.subagents_mut(|coordinator| coordinator.replace_suppressed_sources(sources.clone())); + self.mcp_mut(|coordinator| coordinator.replace_suppressed_sources(sources)); + } + + pub async fn discover_commands( + &self, + requests: Vec, + timeout: Duration, + ) -> DiscoveryBatch { + self.command_lane.discover(requests, timeout).await + } + + pub async fn complete_command( + &self, + deferred: DeferredDiscovery, + ) -> Option<( + CompletedDeferredDiscovery, + Option>, + )> { + self.command_lane.complete_deferred(deferred).await + } + + pub async fn resume_abandoned_command( + &self, + deferred: DeferredDiscovery, + ) -> Option> { + self.command_lane.resume_abandoned(deferred).await + } + + pub async fn finalize_command( + &self, + completed: CompletedDeferredDiscovery, + ) -> Option { + self.command_lane.finalize_deferred(completed).await + } + + pub async fn discover_tools( + &self, + requests: Vec, + timeout: Duration, + ) -> DiscoveryBatch { + self.tool_lane.discover(requests, timeout).await + } + + pub async fn complete_tool( + &self, + deferred: DeferredDiscovery, + ) -> Option<( + CompletedDeferredDiscovery, + Option>, + )> { + self.tool_lane.complete_deferred(deferred).await + } + + pub async fn resume_abandoned_tool( + &self, + deferred: DeferredDiscovery, + ) -> Option> { + self.tool_lane.resume_abandoned(deferred).await + } + + pub async fn finalize_tool( + &self, + completed: CompletedDeferredDiscovery, + ) -> Option { + self.tool_lane.finalize_deferred(completed).await + } + + pub async fn discover_subagents( + &self, + requests: Vec, + timeout: Duration, + ) -> DiscoveryBatch { + self.subagent_lane.discover(requests, timeout).await + } + + pub async fn complete_subagent( + &self, + deferred: DeferredDiscovery, + ) -> Option<( + CompletedDeferredDiscovery, + Option>, + )> { + self.subagent_lane.complete_deferred(deferred).await + } + + pub async fn resume_abandoned_subagent( + &self, + deferred: DeferredDiscovery, + ) -> Option> { + self.subagent_lane.resume_abandoned(deferred).await + } + + pub async fn finalize_subagent( + &self, + completed: CompletedDeferredDiscovery, + ) -> Option { + self.subagent_lane.finalize_deferred(completed).await + } + + pub async fn discover_mcp( + &self, + requests: Vec, + timeout: Duration, + ) -> DiscoveryBatch { + self.mcp_lane.discover(requests, timeout).await + } + + pub async fn complete_mcp( + &self, + deferred: DeferredDiscovery, + ) -> Option<( + CompletedDeferredDiscovery, + Option>, + )> { + self.mcp_lane.complete_deferred(deferred).await + } + + pub async fn resume_abandoned_mcp( + &self, + deferred: DeferredDiscovery, + ) -> Option> { + self.mcp_lane.resume_abandoned(deferred).await + } + + pub async fn finalize_mcp( + &self, + completed: CompletedDeferredDiscovery, + ) -> Option { + self.mcp_lane.finalize_deferred(completed).await + } +} + +fn lock<'a, T>(mutex: &'a Mutex, capability: &str) -> MutexGuard<'a, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => { + log::error!( + "External source coordinator mutex poisoned capability={}", + capability + ); + poisoned.into_inner() + } + } +} diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index eb784eb45c..9587894880 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -4,10 +4,17 @@ //! branches on ecosystem identity. Concrete provider selection remains in the //! product composition root. +mod control_plane; mod mcp; +mod refresh; mod subagent; mod tool; +pub use control_plane::ExternalSourceControlPlane; +pub use refresh::{DeferredDiscovery, DiscoveryBatch}; + +use refresh::{DiscoveryLane, DiscoveryRequest}; + pub use mcp::{ ExternalMcpCoordinator, ExternalMcpCoordinatorSnapshot, ExternalMcpDiscoveryRequest, ExternalMcpDiscoveryResult, @@ -34,6 +41,25 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::sync::Arc; +impl DiscoveryRequest for ExternalSourceDiscoveryRequest { + type Result = ExternalSourceDiscoveryResult; + + const DIAGNOSTIC_PREFIX: &'static str = "external_source"; + const PROVIDER_LABEL: &'static str = "command"; + + fn provider_id(&self) -> ProviderId { + self.provider_id.clone() + } + + fn execute(self) -> Self::Result { + ExternalSourceDiscoveryRequest::execute(self) + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result { + ExternalSourceDiscoveryResult::failed(provider_id, error) + } +} + struct ProviderGeneration { provider: Arc, identity: PromptCommandProviderIdentity, diff --git a/src/crates/assembly/external-sources/src/mcp.rs b/src/crates/assembly/external-sources/src/mcp.rs index e4dd1a62a3..140f8e2c33 100644 --- a/src/crates/assembly/external-sources/src/mcp.rs +++ b/src/crates/assembly/external-sources/src/mcp.rs @@ -10,6 +10,25 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::sync::Arc; +impl crate::DiscoveryRequest for ExternalMcpDiscoveryRequest { + type Result = ExternalMcpDiscoveryResult; + + const DIAGNOSTIC_PREFIX: &'static str = "external_mcp"; + const PROVIDER_LABEL: &'static str = "MCP"; + + fn provider_id(&self) -> ProviderId { + self.provider_id.clone() + } + + fn execute(self) -> Self::Result { + ExternalMcpDiscoveryRequest::execute(self) + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result { + ExternalMcpDiscoveryResult::failed(provider_id, error) + } +} + struct McpProviderGeneration { provider: Arc, identity: ExternalMcpProviderIdentity, diff --git a/src/crates/assembly/external-sources/src/refresh.rs b/src/crates/assembly/external-sources/src/refresh.rs new file mode 100644 index 0000000000..231e01c303 --- /dev/null +++ b/src/crates/assembly/external-sources/src/refresh.rs @@ -0,0 +1,900 @@ +//! Shared asynchronous discovery scheduling for typed external-source providers. + +use bitfun_product_domains::external_sources::{ExternalSourceProviderError, ProviderId}; +use futures::future::{join_all, BoxFuture, Shared}; +use futures::FutureExt; +use std::collections::BTreeMap; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +pub(crate) trait DiscoveryRequest: Send + 'static { + type Result: Clone + Send + Sync + 'static; + + const DIAGNOSTIC_PREFIX: &'static str; + const PROVIDER_LABEL: &'static str; + + fn provider_id(&self) -> ProviderId; + fn execute(self) -> Self::Result; + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result; +} + +type SharedDiscoveryTask = Shared>; +const MAX_CONCURRENT_DISCOVERY_TASKS: usize = 8; +const MAX_DEFERRED_DISCOVERY_LIFETIME: Duration = Duration::from_secs(30); + +fn process_discovery_budget() -> Arc { + static BUDGET: OnceLock> = OnceLock::new(); + Arc::clone( + BUDGET + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DISCOVERY_TASKS))), + ) +} + +struct InFlight { + generation: u64, + task: SharedDiscoveryTask, + deferred_claimed: bool, + pending_request: Option, + abandoned: bool, +} + +pub struct DeferredDiscovery { + provider_id: ProviderId, + generation: u64, + task: SharedDiscoveryTask, +} + +pub struct CompletedDeferredDiscovery { + provider_id: ProviderId, + generation: u64, + result: T, + release_lane: bool, +} + +pub struct DiscoveryBatch { + pub immediate: Vec, + pub deferred: Vec>, +} + +pub(crate) struct DiscoveryLane { + tasks: tokio::sync::Mutex>>, + next_generation: AtomicU64, + budget: Arc, + deferred_lifetime: Duration, + request: PhantomData, +} + +impl Default for DiscoveryLane { + fn default() -> Self { + Self::new() + } +} + +impl DiscoveryLane { + pub(crate) fn new() -> Self { + Self { + tasks: tokio::sync::Mutex::new(BTreeMap::new()), + next_generation: AtomicU64::new(1), + budget: process_discovery_budget(), + deferred_lifetime: MAX_DEFERRED_DISCOVERY_LIFETIME, + request: PhantomData, + } + } + + #[cfg(test)] + fn with_limits(max_concurrent: usize, deferred_lifetime: Duration) -> Self { + Self { + tasks: tokio::sync::Mutex::new(BTreeMap::new()), + next_generation: AtomicU64::new(1), + budget: Arc::new(tokio::sync::Semaphore::new(max_concurrent)), + deferred_lifetime, + request: PhantomData, + } + } + + pub(crate) async fn discover( + &self, + requests: Vec, + timeout: Duration, + ) -> DiscoveryBatch { + let mut scheduled = Vec::with_capacity(requests.len()); + { + let mut tasks = self.tasks.lock().await; + for request in requests { + let provider_id = request.provider_id(); + if let Some(in_flight) = tasks.get_mut(&provider_id) { + // Coalesce repeated watcher/manual refreshes to the newest + // request, but never publish the generation that started + // before the newest request was observed. + in_flight.pending_request = Some(request); + scheduled.push(Scheduled { + provider_id, + generation: in_flight.generation, + task: in_flight.task.clone(), + is_new: false, + abandoned: in_flight.abandoned, + }); + continue; + } + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + let task = spawn_discovery_task::( + request, + provider_id.clone(), + Arc::clone(&self.budget), + ); + tasks.insert( + provider_id.clone(), + InFlight { + generation, + task: task.clone(), + deferred_claimed: false, + pending_request: None, + abandoned: false, + }, + ); + scheduled.push(Scheduled { + provider_id, + generation, + task, + is_new: true, + abandoned: false, + }); + } + } + + let polled = join_all(scheduled.into_iter().map(|scheduled| async move { + if !scheduled.is_new { + return match scheduled.task.clone().now_or_never() { + Some(result) => RawPoll::Complete { + provider_id: scheduled.provider_id, + generation: scheduled.generation, + result, + }, + None => RawPoll::InFlight { + provider_id: scheduled.provider_id, + generation: scheduled.generation, + abandoned: scheduled.abandoned, + }, + }; + } + match tokio::time::timeout(timeout, scheduled.task.clone()).await { + Ok(result) => RawPoll::Complete { + provider_id: scheduled.provider_id, + generation: scheduled.generation, + result, + }, + Err(_) => RawPoll::TimedOut { + provider_id: scheduled.provider_id, + generation: scheduled.generation, + task: scheduled.task, + }, + } + })) + .await; + + let mut immediate = Vec::with_capacity(polled.len()); + let mut deferred = Vec::new(); + let mut tasks = self.tasks.lock().await; + for poll in polled { + match poll { + RawPoll::Complete { + provider_id, + generation, + result, + } => { + if let Some(request) = + take_pending_request(&mut tasks, &provider_id, generation) + { + let next_generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + let task = spawn_discovery_task::( + request, + provider_id.clone(), + Arc::clone(&self.budget), + ); + tasks.insert( + provider_id.clone(), + InFlight { + generation: next_generation, + task: task.clone(), + deferred_claimed: true, + pending_request: None, + abandoned: false, + }, + ); + deferred.push(DeferredDiscovery { + provider_id: provider_id.clone(), + generation: next_generation, + task, + }); + immediate.push(R::failed( + provider_id, + discovery_error::( + "discovery_in_progress", + format!( + "{} provider changed during discovery; checking its newest version", + R::PROVIDER_LABEL + ), + ), + )); + } else if remove_generation(&mut tasks, &provider_id, generation) { + immediate.push(result); + } + } + RawPoll::InFlight { + provider_id, + generation, + abandoned, + } => { + if generation_is_current(&tasks, &provider_id, generation) { + let (suffix, message) = if abandoned { + ( + "discovery_abandoned", + format!( + "{} provider discovery exceeded its maximum background lifetime and remains isolated until its worker exits", + R::PROVIDER_LABEL + ), + ) + } else { + ( + "discovery_in_progress", + format!( + "{} provider discovery is still running; using its last valid version", + R::PROVIDER_LABEL + ), + ) + }; + immediate.push(R::failed( + provider_id, + discovery_error::(suffix, message), + )); + } + } + RawPoll::TimedOut { + provider_id, + generation, + task, + } => { + let claim = tasks + .get_mut(&provider_id) + .filter(|in_flight| in_flight.generation == generation) + .is_some_and(|in_flight| { + if in_flight.deferred_claimed { + false + } else { + in_flight.deferred_claimed = true; + true + } + }); + if claim { + deferred.push(DeferredDiscovery { + provider_id: provider_id.clone(), + generation, + task, + }); + } + immediate.push(R::failed( + provider_id, + discovery_error::( + "discovery_timeout", + format!( + "{} provider discovery exceeded the {} ms deadline", + R::PROVIDER_LABEL, + timeout.as_millis() + ), + ), + )); + } + } + } + DiscoveryBatch { + immediate, + deferred, + } + } + + pub(crate) async fn complete_deferred( + &self, + mut deferred: DeferredDiscovery, + ) -> Option<( + CompletedDeferredDiscovery, + Option>, + )> { + loop { + let provider_id = deferred.provider_id.clone(); + let generation = deferred.generation; + let result = match tokio::time::timeout(self.deferred_lifetime, deferred.task.clone()) + .await + { + Ok(result) => result, + Err(_) => { + let mut tasks = self.tasks.lock().await; + let in_flight = tasks + .get_mut(&provider_id) + .filter(|in_flight| in_flight.generation == generation)?; + // `spawn_blocking` cannot be cancelled safely. Keep this + // generation as a tombstone so repeated refreshes cannot + // accumulate workers or process-wide permits. A later + // refresh may replace it only after the worker really exits. + in_flight.abandoned = true; + return Some(( + CompletedDeferredDiscovery { + provider_id: provider_id.clone(), + generation, + result: R::failed( + provider_id, + discovery_error::( + "discovery_abandoned", + format!( + "{} provider discovery exceeded its maximum background lifetime", + R::PROVIDER_LABEL + ), + ), + ), + release_lane: false, + }, + Some(deferred), + )); + } + }; + let mut tasks = self.tasks.lock().await; + if let Some(request) = + take_pending_request(&mut tasks, &deferred.provider_id, deferred.generation) + { + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + let task = spawn_discovery_task::( + request, + deferred.provider_id.clone(), + Arc::clone(&self.budget), + ); + tasks.insert( + deferred.provider_id.clone(), + InFlight { + generation, + task: task.clone(), + deferred_claimed: true, + pending_request: None, + abandoned: false, + }, + ); + deferred = DeferredDiscovery { + provider_id: deferred.provider_id, + generation, + task, + }; + drop(tasks); + continue; + } + if !generation_is_current(&tasks, &deferred.provider_id, deferred.generation) { + return None; + } + return Some(( + CompletedDeferredDiscovery { + provider_id: deferred.provider_id, + generation: deferred.generation, + result, + release_lane: true, + }, + None, + )); + } + } + + pub(crate) async fn resume_abandoned( + &self, + deferred: DeferredDiscovery, + ) -> Option> { + let provider_id = deferred.provider_id; + let generation = deferred.generation; + // `spawn_blocking` cannot be cancelled. This is the single observer for + // the shared task: it retains no additional semaphore permit and only + // releases the provider lane after the original worker really exits. + let _ = deferred.task.await; + + let mut tasks = self.tasks.lock().await; + let in_flight = tasks + .get(&provider_id) + .filter(|in_flight| in_flight.generation == generation && in_flight.abandoned)?; + if in_flight.pending_request.is_none() { + tasks.remove(&provider_id); + return None; + } + let request = take_pending_request(&mut tasks, &provider_id, generation)?; + let next_generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + let task = + spawn_discovery_task::(request, provider_id.clone(), Arc::clone(&self.budget)); + tasks.insert( + provider_id.clone(), + InFlight { + generation: next_generation, + task: task.clone(), + deferred_claimed: true, + pending_request: None, + abandoned: false, + }, + ); + Some(DeferredDiscovery { + provider_id, + generation: next_generation, + task, + }) + } + + pub(crate) async fn finalize_deferred( + &self, + completed: CompletedDeferredDiscovery, + ) -> Option { + let mut tasks = self.tasks.lock().await; + if !generation_is_current(&tasks, &completed.provider_id, completed.generation) { + return None; + } + if completed.release_lane { + tasks.remove(&completed.provider_id); + } + Some(completed.result) + } + + #[cfg(test)] + async fn cancel(&self, provider_id: &str) -> bool { + let mut tasks = self.tasks.lock().await; + let Some(key) = tasks + .keys() + .find(|candidate| candidate.as_str() == provider_id) + .cloned() + else { + return false; + }; + tasks.remove(&key).is_some() + } +} + +struct Scheduled { + provider_id: ProviderId, + generation: u64, + task: SharedDiscoveryTask, + is_new: bool, + abandoned: bool, +} + +enum RawPoll { + Complete { + provider_id: ProviderId, + generation: u64, + result: T, + }, + InFlight { + provider_id: ProviderId, + generation: u64, + abandoned: bool, + }, + TimedOut { + provider_id: ProviderId, + generation: u64, + task: SharedDiscoveryTask, + }, +} + +fn discovery_error( + suffix: &str, + message: String, +) -> ExternalSourceProviderError { + ExternalSourceProviderError::new( + format!("{}.{}", R::DIAGNOSTIC_PREFIX, suffix), + message, + true, + ) +} + +fn spawn_discovery_task( + request: R, + provider_id: ProviderId, + budget: Arc, +) -> SharedDiscoveryTask { + async move { + let permit = match budget.try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + return R::failed( + provider_id, + discovery_error::( + "discovery_overloaded", + format!( + "{} provider discovery could not start because the process-wide discovery budget is full", + R::PROVIDER_LABEL + ), + ), + ); + } + }; + match tokio::task::spawn_blocking(move || { + let _permit = permit; + request.execute() + }) + .await + { + Ok(result) => result, + Err(error) => R::failed( + provider_id, + discovery_error::( + "discovery_task_failed", + format!( + "{} provider discovery task failed: {error}", + R::PROVIDER_LABEL + ), + ), + ), + } + } + .boxed() + .shared() +} + +fn generation_is_current( + tasks: &BTreeMap>, + provider_id: &ProviderId, + generation: u64, +) -> bool { + tasks + .get(provider_id) + .is_some_and(|in_flight| in_flight.generation == generation) +} + +fn take_pending_request( + tasks: &mut BTreeMap>, + provider_id: &ProviderId, + generation: u64, +) -> Option { + tasks + .get_mut(provider_id) + .filter(|in_flight| in_flight.generation == generation) + .and_then(|in_flight| in_flight.pending_request.take()) +} + +fn remove_generation( + tasks: &mut BTreeMap>, + provider_id: &ProviderId, + generation: u64, +) -> bool { + if generation_is_current(tasks, provider_id, generation) { + tasks.remove(provider_id); + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::{DiscoveryLane, DiscoveryRequest}; + use bitfun_product_domains::external_sources::{ExternalSourceProviderError, ProviderId}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum FakeResultKind { + Success, + InFlight, + TimedOut, + Abandoned, + Overloaded, + TaskFailed, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct FakeResult { + provider_id: ProviderId, + kind: FakeResultKind, + version: u64, + } + + struct FakeRequest { + provider_id: ProviderId, + released: Arc, + version: u64, + } + + impl FakeRequest { + fn blocked(provider_id: &str, released: Arc) -> Self { + Self { + provider_id: ProviderId::new(provider_id).unwrap(), + released, + version: 1, + } + } + + fn blocked_version(provider_id: &str, released: Arc, version: u64) -> Self { + Self { + provider_id: ProviderId::new(provider_id).unwrap(), + released, + version, + } + } + } + + impl DiscoveryRequest for FakeRequest { + type Result = FakeResult; + + const DIAGNOSTIC_PREFIX: &'static str = "fake"; + const PROVIDER_LABEL: &'static str = "fake"; + + fn provider_id(&self) -> ProviderId { + self.provider_id.clone() + } + + fn execute(self) -> Self::Result { + while !self.released.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(1)); + } + FakeResult { + provider_id: self.provider_id, + kind: FakeResultKind::Success, + version: self.version, + } + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result { + let kind = match error.code.as_str() { + "fake.discovery_in_progress" => FakeResultKind::InFlight, + "fake.discovery_timeout" => FakeResultKind::TimedOut, + "fake.discovery_abandoned" => FakeResultKind::Abandoned, + "fake.discovery_overloaded" => FakeResultKind::Overloaded, + "fake.discovery_task_failed" => FakeResultKind::TaskFailed, + code => panic!("unexpected failure code: {code}"), + }; + FakeResult { + provider_id, + kind, + version: 0, + } + } + } + + #[tokio::test] + async fn timed_out_request_is_reused_and_completes_once() { + let lane = DiscoveryLane::::new(); + let released = Arc::new(AtomicBool::new(false)); + + let first = lane + .discover( + vec![FakeRequest::blocked("provider", Arc::clone(&released))], + Duration::from_millis(10), + ) + .await; + assert_eq!(first.immediate[0].kind, FakeResultKind::TimedOut); + assert_eq!(first.deferred.len(), 1); + + let second = lane + .discover( + vec![FakeRequest::blocked("provider", Arc::clone(&released))], + Duration::from_millis(10), + ) + .await; + assert_eq!(second.immediate[0].kind, FakeResultKind::InFlight); + assert!(second.deferred.is_empty()); + + released.store(true, Ordering::Release); + let completed = lane + .complete_deferred(first.deferred.into_iter().next().unwrap()) + .await + .expect("the original generation completes once") + .0; + let completed = lane + .finalize_deferred(completed) + .await + .expect("the original generation is still current at publication"); + assert_eq!(completed.kind, FakeResultKind::Success); + + let third = lane + .discover( + vec![FakeRequest::blocked("provider", released)], + Duration::from_millis(10), + ) + .await; + assert_eq!(third.immediate[0].kind, FakeResultKind::Success); + } + + #[tokio::test] + async fn stale_deferred_completion_does_not_remove_a_newer_generation() { + let lane = DiscoveryLane::::new(); + let first_release = Arc::new(AtomicBool::new(false)); + let first = lane + .discover( + vec![FakeRequest::blocked("provider", Arc::clone(&first_release))], + Duration::from_millis(10), + ) + .await; + lane.cancel("provider").await; + + let second_release = Arc::new(AtomicBool::new(false)); + let second = lane + .discover( + vec![FakeRequest::blocked( + "provider", + Arc::clone(&second_release), + )], + Duration::from_millis(10), + ) + .await; + + first_release.store(true, Ordering::Release); + assert!(lane + .complete_deferred(first.deferred.into_iter().next().unwrap()) + .await + .is_none()); + + let reused = lane + .discover( + vec![FakeRequest::blocked( + "provider", + Arc::clone(&second_release), + )], + Duration::from_millis(10), + ) + .await; + assert_eq!(reused.immediate[0].kind, FakeResultKind::InFlight); + + second_release.store(true, Ordering::Release); + let completed = lane + .complete_deferred(second.deferred.into_iter().next().unwrap()) + .await + .expect("new generation remains registered") + .0; + let completed = lane + .finalize_deferred(completed) + .await + .expect("new generation remains current at publication"); + assert_eq!(completed.kind, FakeResultKind::Success); + } + + #[tokio::test] + async fn refresh_during_in_flight_discovery_publishes_only_the_newest_request() { + let lane = DiscoveryLane::::new(); + let first_release = Arc::new(AtomicBool::new(false)); + let first = lane + .discover( + vec![FakeRequest::blocked_version( + "provider", + Arc::clone(&first_release), + 1, + )], + Duration::from_millis(10), + ) + .await; + assert_eq!(first.immediate[0].kind, FakeResultKind::TimedOut); + + let second_release = Arc::new(AtomicBool::new(true)); + let second = lane + .discover( + vec![FakeRequest::blocked_version("provider", second_release, 2)], + Duration::from_millis(10), + ) + .await; + assert_eq!(second.immediate[0].kind, FakeResultKind::InFlight); + + first_release.store(true, Ordering::Release); + let completed = lane + .complete_deferred(first.deferred.into_iter().next().unwrap()) + .await + .expect("the dirty lane reruns its newest request") + .0; + let completed = lane + .finalize_deferred(completed) + .await + .expect("the newest request remains current at publication"); + assert_eq!(completed.version, 2); + } + + #[tokio::test] + async fn deferred_completion_is_rechecked_at_the_publication_boundary() { + let lane = DiscoveryLane::::new(); + let first_release = Arc::new(AtomicBool::new(false)); + let first = lane + .discover( + vec![FakeRequest::blocked_version( + "provider", + Arc::clone(&first_release), + 1, + )], + Duration::from_millis(10), + ) + .await; + + first_release.store(true, Ordering::Release); + let stale_completion = lane + .complete_deferred(first.deferred.into_iter().next().unwrap()) + .await + .expect("first generation finishes before publication") + .0; + + let newer = lane + .discover( + vec![FakeRequest::blocked_version( + "provider", + Arc::new(AtomicBool::new(true)), + 2, + )], + Duration::from_millis(10), + ) + .await; + assert_eq!(newer.immediate[0].kind, FakeResultKind::InFlight); + assert!(lane.finalize_deferred(stale_completion).await.is_none()); + + let newer_completion = lane + .complete_deferred(newer.deferred.into_iter().next().unwrap()) + .await + .expect("newer generation finishes") + .0; + let newer_result = lane + .finalize_deferred(newer_completion) + .await + .expect("newer generation is published"); + assert_eq!(newer_result.version, 2); + } + + #[tokio::test] + async fn permanently_blocked_discovery_keeps_one_provider_tombstone() { + let lane = DiscoveryLane::::with_limits(1, Duration::from_millis(20)); + let blocked_release = Arc::new(AtomicBool::new(false)); + let first = lane + .discover( + vec![FakeRequest::blocked( + "blocked", + Arc::clone(&blocked_release), + )], + Duration::from_millis(5), + ) + .await; + let (expired, observer) = lane + .complete_deferred(first.deferred.into_iter().next().unwrap()) + .await + .expect("expired discovery publishes a bounded failure"); + let expired = lane + .finalize_deferred(expired) + .await + .expect("the abandoned generation remains current"); + assert_eq!(expired.kind, FakeResultKind::Abandoned); + + let repeated = lane + .discover( + vec![FakeRequest::blocked_version( + "blocked", + Arc::clone(&blocked_release), + 3, + )], + Duration::from_millis(5), + ) + .await; + assert_eq!(repeated.immediate[0].kind, FakeResultKind::Abandoned); + assert!(repeated.deferred.is_empty()); + + let second = lane + .discover( + vec![FakeRequest::blocked( + "replacement", + Arc::new(AtomicBool::new(true)), + )], + Duration::from_millis(5), + ) + .await; + assert_eq!(second.immediate[0].kind, FakeResultKind::Overloaded); + assert!(second.deferred.is_empty()); + + 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) + .await + .expect("the newest pending request runs after the abandoned worker exits") + .0; + let resumed = lane + .finalize_deferred(resumed) + .await + .expect("the resumed generation remains current"); + assert_eq!(resumed.version, 3); + } +} diff --git a/src/crates/assembly/external-sources/src/subagent.rs b/src/crates/assembly/external-sources/src/subagent.rs index 6ff3b7c514..59d41bea53 100644 --- a/src/crates/assembly/external-sources/src/subagent.rs +++ b/src/crates/assembly/external-sources/src/subagent.rs @@ -12,6 +12,25 @@ use std::fmt; use std::sync::Arc; use std::time::{Duration, Instant}; +impl crate::DiscoveryRequest for ExternalSubagentDiscoveryRequest { + type Result = ExternalSubagentDiscoveryResult; + + const DIAGNOSTIC_PREFIX: &'static str = "external_subagent"; + const PROVIDER_LABEL: &'static str = "subagent"; + + fn provider_id(&self) -> ProviderId { + self.provider_id.clone() + } + + fn execute(self) -> Self::Result { + ExternalSubagentDiscoveryRequest::execute(self) + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result { + ExternalSubagentDiscoveryResult::failed(provider_id, error) + } +} + const EXTERNAL_SUBAGENT_LAST_VALID_MAX_AGE: Duration = Duration::from_secs(30); const EXTERNAL_SUBAGENT_LAST_VALID_MAX_FAILURES: u32 = 3; diff --git a/src/crates/assembly/external-sources/src/tool.rs b/src/crates/assembly/external-sources/src/tool.rs index 2a5d76daad..912998e595 100644 --- a/src/crates/assembly/external-sources/src/tool.rs +++ b/src/crates/assembly/external-sources/src/tool.rs @@ -9,6 +9,25 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::sync::Arc; +impl crate::DiscoveryRequest for ExternalToolDiscoveryRequest { + type Result = ExternalToolDiscoveryResult; + + const DIAGNOSTIC_PREFIX: &'static str = "external_tool"; + const PROVIDER_LABEL: &'static str = "tool"; + + fn provider_id(&self) -> ProviderId { + self.provider_id.clone() + } + + fn execute(self) -> Self::Result { + ExternalToolDiscoveryRequest::execute(self) + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result { + ExternalToolDiscoveryResult::failed(provider_id, error) + } +} + struct ToolProviderGeneration { provider: Arc, identity: ExternalToolProviderIdentity, diff --git a/src/crates/assembly/external-sources/tests/control_plane.rs b/src/crates/assembly/external-sources/tests/control_plane.rs new file mode 100644 index 0000000000..55911ff251 --- /dev/null +++ b/src/crates/assembly/external-sources/tests/control_plane.rs @@ -0,0 +1,51 @@ +use bitfun_external_sources::ExternalSourceControlPlane; +use bitfun_product_domains::external_sources::{ExecutionDomainId, ExternalSourceContext}; +use std::collections::BTreeSet; + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: None, + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn control_plane_owns_all_typed_coordinator_snapshots() { + let plane = + ExternalSourceControlPlane::new(context(), Vec::new(), Vec::new(), Vec::new(), Vec::new()) + .unwrap(); + + assert!(plane.commands(|coordinator| coordinator.snapshot().sources.is_empty())); + assert!(plane.tools(|coordinator| coordinator.snapshot().sources.is_empty())); + assert!(plane.subagents(|coordinator| coordinator.snapshot().sources.is_empty())); + assert!(plane.mcp(|coordinator| coordinator.snapshot().sources.is_empty())); +} + +#[test] +fn suppression_replacement_is_applied_to_every_typed_coordinator() { + let plane = + ExternalSourceControlPlane::new(context(), Vec::new(), Vec::new(), Vec::new(), Vec::new()) + .unwrap(); + let suppressed = ["source-key".to_string()] + .into_iter() + .collect::>(); + + plane.replace_suppressed_sources(suppressed.clone()); + + assert_eq!( + plane.commands(|coordinator| coordinator.suppressed_sources().clone()), + suppressed + ); + assert_eq!( + plane.tools(|coordinator| coordinator.suppressed_sources().clone()), + suppressed + ); + assert_eq!( + plane.subagents(|coordinator| coordinator.suppressed_sources().clone()), + suppressed + ); + assert_eq!( + plane.mcp(|coordinator| coordinator.suppressed_sources().clone()), + suppressed + ); +} diff --git a/src/crates/contracts/product-domains/src/external_source_control.rs b/src/crates/contracts/product-domains/src/external_source_control.rs new file mode 100644 index 0000000000..3f19051079 --- /dev/null +++ b/src/crates/contracts/product-domains/src/external_source_control.rs @@ -0,0 +1,752 @@ +//! Versioned control-plane facts for external AI application sources. +//! +//! Capability payloads remain in their typed owners. This module projects only +//! lifecycle, policy, support, runtime, diagnostics, and closed control actions +//! that product surfaces and remote hosts need to share. + +use crate::external_sources::{ + ExecutionDomainId, ExternalMcpActivationState, ExternalSourceCatalogSnapshot, + ExternalSourceDiagnostic, ExternalSourceHostCapabilities, ExternalSourceLifecycleState, + ExternalSourcePublicSnapshot, ExternalSourceScope, ExternalToolActivationState, +}; +use crate::external_subagents::ExternalSubagentActivationState; +use serde::{Deserialize, Serialize}; + +pub const EXTERNAL_SOURCE_CONTROL_SCHEMA_V1: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSourceOperationStage { + ValidateRequest, + Discover, + Reconcile, + ApplyPreference, + ActivateRuntime, + ProjectResponse, + ExecuteRemote, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ExternalSourceRecoveryActionV1 { + Refresh, + Retry, + Review, + ResolveConflict, + InstallRuntime, + ReconnectHost, + ExitSafeMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSourceDiscoveryState { + Pending, + Current, + LastKnownGood, + Failed, + Removed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSourceDesiredState { + Enabled, + Disabled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "state", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ExternalSourceReviewState { + NotRequired, + Required { content_version: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSourceRuntimeState { + NotApplicable, + Inactive, + Starting, + Active, + Degraded, + Quarantined, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSourceSupportState { + Supported, + Partial, + Unsupported, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSourceEffectiveStatus { + Discovering, + Disabled, + ReviewRequired, + Conflict, + Active, + Degraded, + Unsupported, + Available, + Removed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalCapabilityKindV1 { + Command, + Tool, + Subagent, + Mcp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceControlSourceV1 { + pub stable_key: String, + pub ecosystem_id: String, + pub display_name: String, + pub scope: ExternalSourceScope, + pub content_version: String, + pub discovery: ExternalSourceDiscoveryState, + pub desired: ExternalSourceDesiredState, + pub review: ExternalSourceReviewState, + pub runtime: ExternalSourceRuntimeState, + pub support: ExternalSourceSupportState, + pub effective_status: ExternalSourceEffectiveStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalCapabilityControlV1 { + pub kind: ExternalCapabilityKindV1, + pub revision: u64, + pub item_count: usize, + pub pending_review_count: usize, + pub unresolved_conflict_count: usize, + pub runtime: ExternalSourceRuntimeState, + pub support: ExternalSourceSupportState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceControlSnapshotV1 { + pub schema_version: u32, + pub execution_domain_id: ExecutionDomainId, + pub refresh_generation: u64, + pub preference_revision: u64, + pub safe_mode: bool, + pub host_capabilities: ExternalSourceHostCapabilities, + pub sources: Vec, + pub capabilities: Vec, + pub diagnostics: Vec, + pub recovery_actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceSurfaceSnapshotV1 { + pub control: ExternalSourceControlSnapshotV1, + pub catalog: ExternalSourcePublicSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ExternalSourceControlActionV1 { + Refresh, + SetSourceEnabled { source_key: String, enabled: bool }, + SetSafeMode { enabled: bool }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceControlRequestV1 { + pub schema_version: u32, + pub operation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_preference_revision: Option, + pub action: ExternalSourceControlActionV1, +} + +impl ExternalSourceControlRequestV1 { + pub fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != EXTERNAL_SOURCE_CONTROL_SCHEMA_V1 { + return Err("unsupported external source control schema"); + } + if self.operation_id.is_empty() + || self.operation_id.len() > 160 + || self.operation_id.trim() != self.operation_id + || self.operation_id.chars().any(char::is_control) + { + return Err("invalid external source control operation id"); + } + match &self.action { + ExternalSourceControlActionV1::SetSourceEnabled { source_key, .. } + if source_key.is_empty() + || source_key.len() > 4096 + || source_key.chars().any(char::is_control) => + { + Err("invalid external source key") + } + _ => Ok(()), + } + } +} + +impl ExternalSourceControlSnapshotV1 { + pub fn from_catalog( + catalog: &ExternalSourceCatalogSnapshot, + execution_domain_id: ExecutionDomainId, + safe_mode: bool, + host_capabilities: ExternalSourceHostCapabilities, + ) -> Self { + let sources = catalog + .sources + .iter() + .map(|source| project_source(catalog, source, safe_mode)) + .collect(); + let capabilities = vec![ + command_control(catalog), + tool_control(catalog, safe_mode), + subagent_control(catalog, safe_mode), + mcp_control(catalog, safe_mode), + ]; + let mut recovery_actions = Vec::new(); + if catalog.discovery_pending || !catalog.diagnostics.is_empty() { + recovery_actions.push(ExternalSourceRecoveryActionV1::Refresh); + } + if !catalog.tool_approval_requests.is_empty() + || !catalog.mcp_approval_requests.is_empty() + || !catalog.pending_subagent_approvals.is_empty() + { + recovery_actions.push(ExternalSourceRecoveryActionV1::Review); + } + if catalog + .command_conflicts + .iter() + .any(|conflict| conflict.selected_candidate_id.is_none()) + || catalog + .tool_conflicts + .iter() + .any(|conflict| conflict.selected_candidate_id.is_none()) + || catalog + .mcp_conflicts + .iter() + .any(|conflict| conflict.selected_candidate_id.is_none()) + || catalog + .subagent_conflicts + .iter() + .any(|conflict| conflict.selected_candidate_id.is_none()) + { + recovery_actions.push(ExternalSourceRecoveryActionV1::ResolveConflict); + } + if catalog.diagnostics.iter().any(|diagnostic| { + diagnostic.code.contains("runtime") || diagnostic.code.contains("dependency") + }) { + recovery_actions.push(ExternalSourceRecoveryActionV1::InstallRuntime); + } + if safe_mode { + recovery_actions.push(ExternalSourceRecoveryActionV1::ExitSafeMode); + } + Self { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + execution_domain_id, + refresh_generation: catalog.generation, + preference_revision: catalog.preference_revision, + safe_mode, + host_capabilities, + sources, + capabilities, + diagnostics: catalog.diagnostics.clone(), + recovery_actions, + } + } +} + +fn project_source( + catalog: &ExternalSourceCatalogSnapshot, + source: &crate::external_sources::ExternalSourceCatalogEntry, + safe_mode: bool, +) -> ExternalSourceControlSourceV1 { + let discovery = match source.lifecycle { + ExternalSourceLifecycleState::Available + | ExternalSourceLifecycleState::Restricted + | ExternalSourceLifecycleState::Degraded => ExternalSourceDiscoveryState::Current, + ExternalSourceLifecycleState::UsingLastValidVersion => { + ExternalSourceDiscoveryState::LastKnownGood + } + ExternalSourceLifecycleState::Unavailable => ExternalSourceDiscoveryState::Failed, + ExternalSourceLifecycleState::Removed => ExternalSourceDiscoveryState::Removed, + ExternalSourceLifecycleState::Suppressed => ExternalSourceDiscoveryState::Current, + }; + let desired = if matches!(source.lifecycle, ExternalSourceLifecycleState::Suppressed) { + ExternalSourceDesiredState::Disabled + } else { + ExternalSourceDesiredState::Enabled + }; + let review = review_state(catalog, &source.record.key, &source.record.content_version); + let runtime = source_runtime(catalog, &source.record.key, safe_mode); + let support = match source.record.health { + crate::external_sources::ExternalSourceHealth::Available => { + ExternalSourceSupportState::Supported + } + crate::external_sources::ExternalSourceHealth::Partial + | crate::external_sources::ExternalSourceHealth::Degraded => { + ExternalSourceSupportState::Partial + } + crate::external_sources::ExternalSourceHealth::Unavailable => { + ExternalSourceSupportState::Unavailable + } + }; + let has_conflict = source_has_conflict(catalog, &source.record.key); + let effective_status = + effective_status(discovery, desired, &review, runtime, support, has_conflict); + ExternalSourceControlSourceV1 { + stable_key: source.stable_key.clone(), + ecosystem_id: source.record.ecosystem_id.as_str().to_string(), + display_name: source.record.display_name.clone(), + scope: source.record.scope, + content_version: source.record.content_version.clone(), + discovery, + desired, + review, + runtime, + support, + effective_status, + } +} + +fn review_state( + catalog: &ExternalSourceCatalogSnapshot, + source: &crate::external_sources::SourceKey, + content_version: &str, +) -> ExternalSourceReviewState { + let pending_tool = catalog + .tool_approval_requests + .iter() + .any(|request| request.target_id.source == *source); + let pending_mcp = catalog + .mcp_approval_requests + .iter() + .any(|request| request.definition.id.source == *source); + let pending_subagent = catalog.subagents.iter().any(|candidate| { + catalog + .pending_subagent_approvals + .contains(&candidate.candidate_id) + && candidate.source_keys.contains(source) + }); + if pending_tool || pending_mcp || pending_subagent { + ExternalSourceReviewState::Required { + content_version: content_version.to_string(), + } + } else { + // Activation states combine review and runtime outcomes, so they + // cannot truthfully reconstruct an independent approved/declined fact + // after load failures or mixed per-source decisions. V1 therefore + // reports only the review fact the catalog owns directly: pending or + // not pending. + ExternalSourceReviewState::NotRequired + } +} + +fn source_runtime( + catalog: &ExternalSourceCatalogSnapshot, + source: &crate::external_sources::SourceKey, + safe_mode: bool, +) -> ExternalSourceRuntimeState { + let tool_states = catalog.tools.iter().filter_map(|entry| { + (entry.definition.id.target.source == *source).then_some(&entry.activation) + }); + let mcp_states = catalog.mcp_servers.iter().filter_map(|entry| { + (entry.definition.id.source == *source).then_some(&entry.activation_state) + }); + let subagent_states = catalog.subagents.iter().filter_map(|entry| { + entry + .source_keys + .contains(source) + .then_some(&entry.activation_state) + }); + + let mut applicable = false; + let mut active = false; + let mut starting = false; + let mut degraded = false; + let mut unsupported = false; + for state in tool_states { + applicable = true; + active |= matches!(state, ExternalToolActivationState::Active); + degraded |= matches!(state, ExternalToolActivationState::LoadFailed { .. }); + unsupported |= matches!( + state, + ExternalToolActivationState::Unsupported { .. } + | ExternalToolActivationState::RuntimeUnavailable { .. } + ); + } + for state in mcp_states { + applicable = true; + active |= matches!(state, ExternalMcpActivationState::Active); + starting |= matches!(state, ExternalMcpActivationState::Starting); + unsupported |= matches!( + state, + ExternalMcpActivationState::Unsupported { .. } + | ExternalMcpActivationState::RuntimeUnavailable { .. } + ); + } + for state in subagent_states { + applicable = true; + active |= matches!(state, ExternalSubagentActivationState::Active); + degraded |= matches!( + state, + ExternalSubagentActivationState::Blocked | ExternalSubagentActivationState::Unavailable + ); + } + if !applicable { + ExternalSourceRuntimeState::NotApplicable + } else if safe_mode { + ExternalSourceRuntimeState::Inactive + } else if degraded { + ExternalSourceRuntimeState::Degraded + } else if unsupported { + ExternalSourceRuntimeState::Unsupported + } else if starting { + ExternalSourceRuntimeState::Starting + } else if active { + ExternalSourceRuntimeState::Active + } else { + ExternalSourceRuntimeState::Inactive + } +} + +fn source_has_conflict( + catalog: &ExternalSourceCatalogSnapshot, + source: &crate::external_sources::SourceKey, +) -> bool { + catalog.command_conflicts.iter().any(|conflict| { + conflict.selected_candidate_id.is_none() + && conflict + .candidates + .iter() + .any(|candidate| candidate.source == *source) + }) || catalog.tool_conflicts.iter().any(|conflict| { + conflict.selected_candidate_id.is_none() + && conflict + .candidates + .iter() + .any(|candidate| candidate.source.as_ref() == Some(source)) + }) || catalog.mcp_conflicts.iter().any(|conflict| { + conflict.selected_candidate_id.is_none() + && conflict + .candidates + .iter() + .any(|candidate| candidate.source.as_ref() == Some(source)) + }) || catalog.subagent_conflicts.iter().any(|conflict| { + conflict.selected_candidate_id.is_none() + && catalog.subagents.iter().any(|candidate| { + candidate.source_keys.contains(source) + && conflict + .candidates + .iter() + .any(|item| item.candidate_id == candidate.candidate_id) + }) + }) +} + +fn effective_status( + discovery: ExternalSourceDiscoveryState, + desired: ExternalSourceDesiredState, + review: &ExternalSourceReviewState, + runtime: ExternalSourceRuntimeState, + support: ExternalSourceSupportState, + has_conflict: bool, +) -> ExternalSourceEffectiveStatus { + if discovery == ExternalSourceDiscoveryState::Pending { + ExternalSourceEffectiveStatus::Discovering + } else if discovery == ExternalSourceDiscoveryState::Removed { + ExternalSourceEffectiveStatus::Removed + } else if desired == ExternalSourceDesiredState::Disabled { + ExternalSourceEffectiveStatus::Disabled + } else if matches!(review, ExternalSourceReviewState::Required { .. }) { + ExternalSourceEffectiveStatus::ReviewRequired + } else if has_conflict { + ExternalSourceEffectiveStatus::Conflict + } else if runtime == ExternalSourceRuntimeState::Active { + ExternalSourceEffectiveStatus::Active + } else if matches!( + runtime, + ExternalSourceRuntimeState::Degraded | ExternalSourceRuntimeState::Quarantined + ) || discovery == ExternalSourceDiscoveryState::LastKnownGood + { + ExternalSourceEffectiveStatus::Degraded + } else if matches!(runtime, ExternalSourceRuntimeState::Unsupported) + || matches!( + support, + ExternalSourceSupportState::Unsupported | ExternalSourceSupportState::Unavailable + ) + { + ExternalSourceEffectiveStatus::Unsupported + } else { + ExternalSourceEffectiveStatus::Available + } +} + +fn command_control(catalog: &ExternalSourceCatalogSnapshot) -> ExternalCapabilityControlV1 { + ExternalCapabilityControlV1 { + kind: ExternalCapabilityKindV1::Command, + revision: catalog.generation, + item_count: catalog.commands.len(), + pending_review_count: 0, + unresolved_conflict_count: catalog + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(), + runtime: ExternalSourceRuntimeState::NotApplicable, + support: capability_support(catalog, ExternalCapabilityKindV1::Command), + } +} + +fn tool_control( + catalog: &ExternalSourceCatalogSnapshot, + safe_mode: bool, +) -> ExternalCapabilityControlV1 { + ExternalCapabilityControlV1 { + kind: ExternalCapabilityKindV1::Tool, + revision: catalog.generation, + item_count: catalog.tools.len(), + pending_review_count: catalog.tool_approval_requests.len(), + unresolved_conflict_count: catalog + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(), + runtime: aggregate_tool_runtime(catalog, safe_mode), + support: capability_support(catalog, ExternalCapabilityKindV1::Tool), + } +} + +fn subagent_control( + catalog: &ExternalSourceCatalogSnapshot, + safe_mode: bool, +) -> ExternalCapabilityControlV1 { + ExternalCapabilityControlV1 { + kind: ExternalCapabilityKindV1::Subagent, + revision: catalog.subagent_generation, + item_count: catalog.subagents.len(), + pending_review_count: catalog.pending_subagent_approvals.len(), + unresolved_conflict_count: catalog + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(), + runtime: aggregate_subagent_runtime( + catalog + .subagents + .iter() + .map(|candidate| &candidate.activation_state), + safe_mode, + ), + support: capability_support(catalog, ExternalCapabilityKindV1::Subagent), + } +} + +fn mcp_control( + catalog: &ExternalSourceCatalogSnapshot, + safe_mode: bool, +) -> ExternalCapabilityControlV1 { + let runtime = aggregate_mcp_runtime( + catalog + .mcp_servers + .iter() + .map(|server| &server.activation_state), + safe_mode, + ); + ExternalCapabilityControlV1 { + kind: ExternalCapabilityKindV1::Mcp, + revision: catalog.mcp_generation, + item_count: catalog.mcp_servers.len(), + pending_review_count: catalog.mcp_approval_requests.len(), + unresolved_conflict_count: catalog + .mcp_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(), + runtime, + support: capability_support(catalog, ExternalCapabilityKindV1::Mcp), + } +} + +fn aggregate_tool_runtime( + catalog: &ExternalSourceCatalogSnapshot, + safe_mode: bool, +) -> ExternalSourceRuntimeState { + aggregate_tool_runtime_states(catalog.tools.iter().map(|tool| &tool.activation), safe_mode) +} + +#[derive(Clone, Copy)] +enum RuntimeFact { + Inactive, + Starting, + Active, + Degraded, + Unsupported, +} + +fn aggregate_runtime( + facts: impl IntoIterator, + safe_mode: bool, +) -> ExternalSourceRuntimeState { + if safe_mode { + return ExternalSourceRuntimeState::Inactive; + } + let mut active = false; + let mut starting = false; + let mut degraded = false; + let mut unsupported = false; + for fact in facts { + match fact { + RuntimeFact::Inactive => {} + RuntimeFact::Starting => starting = true, + RuntimeFact::Active => active = true, + RuntimeFact::Degraded => degraded = true, + RuntimeFact::Unsupported => unsupported = true, + } + } + if degraded { + ExternalSourceRuntimeState::Degraded + } else if unsupported { + ExternalSourceRuntimeState::Unsupported + } else if starting { + ExternalSourceRuntimeState::Starting + } else if active { + ExternalSourceRuntimeState::Active + } else { + ExternalSourceRuntimeState::Inactive + } +} + +fn aggregate_tool_runtime_states<'a>( + states: impl IntoIterator, + safe_mode: bool, +) -> ExternalSourceRuntimeState { + aggregate_runtime( + states.into_iter().map(|state| match state { + ExternalToolActivationState::Active => RuntimeFact::Active, + ExternalToolActivationState::LoadFailed { .. } => RuntimeFact::Degraded, + ExternalToolActivationState::Unsupported { .. } + | ExternalToolActivationState::RuntimeUnavailable { .. } => RuntimeFact::Unsupported, + _ => RuntimeFact::Inactive, + }), + safe_mode, + ) +} + +fn aggregate_subagent_runtime<'a>( + states: impl IntoIterator, + safe_mode: bool, +) -> ExternalSourceRuntimeState { + aggregate_runtime( + states.into_iter().map(|state| match state { + ExternalSubagentActivationState::Active => RuntimeFact::Active, + ExternalSubagentActivationState::Blocked + | ExternalSubagentActivationState::Unavailable => RuntimeFact::Degraded, + _ => RuntimeFact::Inactive, + }), + safe_mode, + ) +} + +fn aggregate_mcp_runtime<'a>( + states: impl IntoIterator, + safe_mode: bool, +) -> ExternalSourceRuntimeState { + aggregate_runtime( + states.into_iter().map(|state| match state { + ExternalMcpActivationState::Active => RuntimeFact::Active, + ExternalMcpActivationState::Starting => RuntimeFact::Starting, + ExternalMcpActivationState::Unsupported { .. } + | ExternalMcpActivationState::RuntimeUnavailable { .. } => RuntimeFact::Unsupported, + _ => RuntimeFact::Inactive, + }), + safe_mode, + ) +} + +fn capability_support( + catalog: &ExternalSourceCatalogSnapshot, + kind: ExternalCapabilityKindV1, +) -> ExternalSourceSupportState { + let asset_kind = match kind { + ExternalCapabilityKindV1::Command => { + crate::external_sources::ExternalSourceAssetKind::Command + } + ExternalCapabilityKindV1::Tool => crate::external_sources::ExternalSourceAssetKind::Tool, + ExternalCapabilityKindV1::Subagent => { + crate::external_sources::ExternalSourceAssetKind::Subagent + } + ExternalCapabilityKindV1::Mcp => crate::external_sources::ExternalSourceAssetKind::Mcp, + }; + if catalog.diagnostics.iter().any(|diagnostic| { + diagnostic.asset_kind == asset_kind + && matches!( + diagnostic.severity, + crate::external_sources::ExternalSourceDiagnosticSeverity::Error + ) + }) { + ExternalSourceSupportState::Partial + } else { + ExternalSourceSupportState::Supported + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_capability_reports_explicit_unsupported_state() { + let state = ExternalToolActivationState::Unsupported { + reason: "runtime is not supported".to_string(), + }; + + assert_eq!( + aggregate_tool_runtime_states([&state], false), + ExternalSourceRuntimeState::Unsupported + ); + } + + #[test] + fn subagent_capability_reports_blocked_state_as_degraded() { + assert_eq!( + aggregate_subagent_runtime([&ExternalSubagentActivationState::Blocked], false), + ExternalSourceRuntimeState::Degraded + ); + } + + #[test] + fn mcp_capability_reports_runtime_unavailable_state() { + let state = ExternalMcpActivationState::RuntimeUnavailable { + reason: "runtime is unavailable".to_string(), + }; + + assert_eq!( + aggregate_mcp_runtime([&state], false), + ExternalSourceRuntimeState::Unsupported + ); + } +} diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 084eedeecb..9435d2fd94 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -76,12 +76,23 @@ pub enum ExternalSourceOperationErrorCode { InvalidRequest, HostUnavailable, HostCapabilityUnavailable, + TrustRequired, PolicyIncompatible, PolicyLimited, StaleRevision, Conflict, NotFound, Unavailable, + RuntimeUnavailable, + Unsupported, + IncompatibleVersion, + DependencyFailed, + Timeout, + Cancelled, + Overloaded, + ProcessLost, + InvalidResponse, + TemporarilyUnavailable, Internal, } @@ -91,12 +102,23 @@ impl ExternalSourceOperationErrorCode { Self::InvalidRequest => "invalid_request", Self::HostUnavailable => "host_unavailable", Self::HostCapabilityUnavailable => "host_capability_unavailable", + Self::TrustRequired => "trust_required", Self::PolicyIncompatible => "policy_incompatible", Self::PolicyLimited => "policy_limited", Self::StaleRevision => "stale_revision", Self::Conflict => "conflict", Self::NotFound => "not_found", Self::Unavailable => "unavailable", + Self::RuntimeUnavailable => "runtime_unavailable", + Self::Unsupported => "unsupported", + Self::IncompatibleVersion => "incompatible_version", + Self::DependencyFailed => "dependency_failed", + Self::Timeout => "timeout", + Self::Cancelled => "cancelled", + Self::Overloaded => "overloaded", + Self::ProcessLost => "process_lost", + Self::InvalidResponse => "invalid_response", + Self::TemporarilyUnavailable => "temporarily_unavailable", Self::Internal => "internal", } } @@ -110,6 +132,12 @@ pub struct ExternalSourceOperationError { pub retryable: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub correlation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub recovery_actions: Vec, } impl ExternalSourceOperationError { @@ -118,17 +146,83 @@ impl ExternalSourceOperationError { detail: impl Into, retryable: bool, ) -> Self { - let detail = detail.into(); + let detail = bounded_error_detail(detail.into()); Self { code, - detail: detail.chars().take(MAX_TEXT_LENGTH).collect(), + detail, retryable, correlation_id: None, + causation_id: None, + stage: None, + recovery_actions: Vec::new(), } } pub fn with_correlation_id(mut self, correlation_id: impl Into) -> Self { - self.correlation_id = Some(correlation_id.into().chars().take(MAX_ID_LENGTH).collect()); + self.correlation_id = valid_error_id(correlation_id.into()); + self + } + + pub fn with_causation_id(mut self, causation_id: impl Into) -> Self { + self.causation_id = valid_error_id(causation_id.into()); + self + } + + pub fn with_stage( + mut self, + stage: crate::external_source_control::ExternalSourceOperationStage, + ) -> Self { + self.stage = Some(stage); + self + } + + pub fn with_recovery_action( + mut self, + action: crate::external_source_control::ExternalSourceRecoveryActionV1, + ) -> Self { + if self.recovery_actions.len() < 8 && !self.recovery_actions.contains(&action) { + self.recovery_actions.push(action); + } + self + } + + pub fn with_default_recovery_actions(mut self) -> Self { + use crate::external_source_control::ExternalSourceRecoveryActionV1 as Recovery; + + let actions: &[Recovery] = match self.code { + ExternalSourceOperationErrorCode::TrustRequired + | ExternalSourceOperationErrorCode::PolicyLimited => &[Recovery::Review], + ExternalSourceOperationErrorCode::StaleRevision + | ExternalSourceOperationErrorCode::NotFound => &[Recovery::Refresh], + ExternalSourceOperationErrorCode::Conflict => { + &[Recovery::Refresh, Recovery::ResolveConflict] + } + ExternalSourceOperationErrorCode::RuntimeUnavailable + | ExternalSourceOperationErrorCode::DependencyFailed => { + &[Recovery::InstallRuntime, Recovery::Retry] + } + ExternalSourceOperationErrorCode::HostUnavailable + | ExternalSourceOperationErrorCode::ProcessLost + | ExternalSourceOperationErrorCode::InvalidResponse => { + &[Recovery::ReconnectHost, Recovery::Retry] + } + ExternalSourceOperationErrorCode::Unavailable + | ExternalSourceOperationErrorCode::Timeout + | ExternalSourceOperationErrorCode::Overloaded + | ExternalSourceOperationErrorCode::TemporarilyUnavailable + | ExternalSourceOperationErrorCode::Internal => &[Recovery::Retry, Recovery::Refresh], + ExternalSourceOperationErrorCode::InvalidRequest + | ExternalSourceOperationErrorCode::HostCapabilityUnavailable + | ExternalSourceOperationErrorCode::PolicyIncompatible + | ExternalSourceOperationErrorCode::Unsupported + | ExternalSourceOperationErrorCode::IncompatibleVersion + | ExternalSourceOperationErrorCode::Cancelled => &[], + }; + for action in actions { + if self.recovery_actions.len() < 8 && !self.recovery_actions.contains(action) { + self.recovery_actions.push(action.clone()); + } + } self } @@ -143,7 +237,19 @@ impl ExternalSourceOperationError { } pub fn decode(encoded: &str) -> Option { - serde_json::from_str(encoded).ok() + let mut decoded: Self = serde_json::from_str(encoded).ok()?; + decoded.detail = bounded_error_detail(decoded.detail); + decoded.correlation_id = decoded.correlation_id.and_then(valid_error_id); + decoded.causation_id = decoded.causation_id.and_then(valid_error_id); + decoded.recovery_actions.truncate(8); + let mut unique = Vec::with_capacity(decoded.recovery_actions.len()); + for action in decoded.recovery_actions { + if !unique.contains(&action) { + unique.push(action); + } + } + decoded.recovery_actions = unique; + Some(decoded) } pub fn host_capability_unavailable(detail: impl Into) -> Self { @@ -163,6 +269,31 @@ impl ExternalSourceOperationError { } } +fn valid_error_id(value: String) -> Option { + validate_id(&value, "external source operation reference") + .is_ok() + .then_some(value) +} + +fn bounded_error_detail(value: String) -> String { + let bounded = value + .chars() + .take(MAX_TEXT_LENGTH) + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::(); + if bounded.trim().is_empty() { + "External source operation failed".to_string() + } else { + bounded + } +} + impl fmt::Display for ExternalSourceOperationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "{}: {}", self.code.as_str(), self.detail) @@ -1478,6 +1609,7 @@ pub struct PromptCommandCatalogEntry { #[non_exhaustive] pub enum ExternalToolActivationState { ApprovalRequired, + Declined, Disabled, Active, Conflict, @@ -1673,6 +1805,13 @@ pub struct ExternalSourceHostCapabilities { pub can_manage_sources: bool, pub can_approve_runtime: bool, pub can_execute_external_assets: bool, + pub can_set_safe_mode: bool, + #[serde(default, skip_serializing_if = "bool_is_false")] + pub can_reveal_source_location: bool, +} + +fn bool_is_false(value: &bool) -> bool { + !*value } impl ExternalSourceHostCapabilities { @@ -1683,6 +1822,17 @@ impl ExternalSourceHostCapabilities { can_manage_sources: true, can_approve_runtime: true, can_execute_external_assets: true, + can_set_safe_mode: true, + can_reveal_source_location: false, + } + } + + /// Desktop-local host actions are intentionally separate from portable + /// read/write control capabilities. Remote and headless hosts fail closed. + pub const fn local_desktop() -> Self { + Self { + can_reveal_source_location: true, + ..Self::read_write() } } @@ -1693,6 +1843,8 @@ impl ExternalSourceHostCapabilities { can_manage_sources: false, can_approve_runtime: false, can_execute_external_assets: false, + can_set_safe_mode: false, + can_reveal_source_location: false, } } } @@ -1748,6 +1900,21 @@ pub struct ExternalSourcePublicSnapshot { pub diagnostics: Vec, } +impl ExternalSourcePublicSnapshot { + /// Down-projects values added after the original catalog contract so an + /// older controller never receives an enum variant it cannot render. + /// New control clients use `ExternalSourceControlSnapshotV1` for the + /// orthogonal review state instead. + pub fn into_legacy_v0_compatible(mut self) -> Self { + for tool in &mut self.tools { + if matches!(tool.activation, ExternalToolActivationState::Declined) { + tool.activation = ExternalToolActivationState::Disabled; + } + } + self + } +} + impl From for ExternalSourcePublicSnapshot { fn from(snapshot: ExternalSourceCatalogSnapshot) -> Self { Self { diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index bfeb1af98f..096f3b9108 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -8,6 +8,9 @@ pub mod canvas; #[cfg(feature = "external-sources")] pub mod external_integration_policy; +#[cfg(feature = "external-sources")] +pub mod external_source_control; + #[cfg(feature = "external-sources")] pub mod external_sources; diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index 2e5a9ef583..9027d8a3bf 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -5,6 +5,11 @@ use bitfun_product_domains::external_integration_policy::{ ExternalIntegrationMode, ExternalIntegrationPolicyDocument, ExternalIntegrationPolicyOverride, ExternalIntegrationPolicyStatus, }; +use bitfun_product_domains::external_source_control::{ + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, + ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, + ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, +}; use bitfun_product_domains::external_sources::{ external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, @@ -14,7 +19,8 @@ use bitfun_product_domains::external_sources::{ ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpStaticStatus, ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, ExternalSourceDiagnostic, - ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError, + ExternalSourceHealth, ExternalSourceHostCapabilities, ExternalSourceLifecycleState, + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, PreparedExternalMcpServer, @@ -550,6 +556,43 @@ fn standalone_tool_contract_separates_static_preview_from_executable_source() { tool.validate().expect("valid standalone tool preview"); } +#[test] +fn legacy_public_snapshot_downprojects_new_tool_review_variants() { + let snapshot: ExternalSourcePublicSnapshot = serde_json::from_value(serde_json::json!({ + "generation": 1, + "discoveryPending": false, + "sources": [], + "commands": [], + "tools": [{ + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "weather.js" + }, + "exportId": "default" + }, + "name": "weather", + "descriptionPreview": "Get weather", + "modulePath": "/.opencode/tools/weather.js", + "workingDirectory": "", + "runtimeKind": "java_script", + "capabilities": [], + "contentVersion": "sha256:v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v1", + "decisionKey": "decision-v1", + "activation": { "state": "declined" } + }] + })) + .expect("new public snapshot"); + + let legacy = + serde_json::to_value(snapshot.into_legacy_v0_compatible()).expect("legacy public snapshot"); + assert_eq!(legacy["tools"][0]["activation"]["state"], "disabled"); +} + #[test] fn standalone_tool_contract_rejects_names_that_are_not_model_callable() { let target = SourceQualifiedToolTargetId::new( @@ -1216,3 +1259,232 @@ fn public_snapshot_never_exposes_executable_prompt_templates() { .get("template") .is_none()); } + +#[test] +fn control_projection_keeps_lifecycle_facts_orthogonal() { + let catalog = ExternalSourceCatalogSnapshot { + generation: 7, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: "opencode.commands:project".to_string(), + presentation_group_id: None, + record: source("opencode.commands", "opencode", "project"), + lifecycle: ExternalSourceLifecycleState::UsingLastValidVersion, + }], + commands: vec![PromptCommandCatalogEntry { + definition: command("opencode.commands", "project", 1), + }], + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 2, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 3, + preference_revision: 11, + subagents: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let control = ExternalSourceControlSnapshotV1::from_catalog( + &catalog, + ExecutionDomainId::new("local-user").unwrap(), + false, + ExternalSourceHostCapabilities::read_write(), + ); + + assert_eq!(control.schema_version, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1); + assert_eq!(control.refresh_generation, 7); + assert_eq!(control.preference_revision, 11); + assert_eq!(control.sources.len(), 1); + assert_eq!( + control.sources[0].discovery, + ExternalSourceDiscoveryState::LastKnownGood + ); + assert_eq!( + control.sources[0].desired, + ExternalSourceDesiredState::Enabled + ); + assert_eq!( + control.sources[0].review, + ExternalSourceReviewState::NotRequired + ); + assert_eq!(control.capabilities.len(), 4); +} + +#[test] +fn control_projection_does_not_infer_review_facts_from_runtime_activation() { + let record = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(record.key.clone(), "docs").unwrap(), + provenance: vec![record.key.clone()], + name: "docs".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://mcp.example.com".to_string()), + header_names: Vec::new(), + source_enabled: true, + behavior_version: "behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let catalog = ExternalSourceCatalogSnapshot { + generation: 1, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: "opencode.mcp:project-config".to_string(), + presentation_group_id: None, + record: record.clone(), + lifecycle: ExternalSourceLifecycleState::Available, + }], + commands: Vec::new(), + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 1, + mcp_servers: vec![ExternalMcpCatalogEntry { + candidate_id: "external_mcp:docs".to_string(), + definition, + approval_key: "approval-v1".to_string(), + decision_key: "decision-v1".to_string(), + runtime_id: None, + activation_state: ExternalMcpActivationState::Declined, + }], + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 1, + preference_revision: 1, + subagents: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let control = ExternalSourceControlSnapshotV1::from_catalog( + &catalog, + ExecutionDomainId::new("local-user").unwrap(), + false, + ExternalSourceHostCapabilities::read_write(), + ); + + assert_eq!( + control.sources[0].review, + ExternalSourceReviewState::NotRequired + ); +} + +#[test] +fn desktop_local_host_capability_is_additive_on_the_wire() { + let portable = serde_json::to_value(ExternalSourceHostCapabilities::read_write()).unwrap(); + let read_only = + serde_json::to_value(ExternalSourceHostCapabilities::read_only_projection()).unwrap(); + let desktop = serde_json::to_value(ExternalSourceHostCapabilities::local_desktop()).unwrap(); + + assert!(portable.get("canRevealSourceLocation").is_none()); + assert!(read_only.get("canRevealSourceLocation").is_none()); + assert_eq!(desktop["canRevealSourceLocation"], true); + + let legacy: ExternalSourceHostCapabilities = serde_json::from_value(serde_json::json!({ + "canRefresh": true, + "canMutatePolicy": true, + "canManageSources": true, + "canApproveRuntime": true, + "canExecuteExternalAssets": true, + "canSetSafeMode": true + })) + .unwrap(); + assert!(!legacy.can_reveal_source_location); +} + +#[test] +fn operation_error_round_trip_preserves_typed_recovery_without_message_parsing() { + let error = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::StaleRevision, + "refresh required", + true, + ) + .with_stage(ExternalSourceOperationStage::ApplyPreference) + .with_causation_id("refresh-generation-7") + .with_recovery_action(ExternalSourceRecoveryActionV1::Refresh); + + let encoded = error.encode(); + assert_eq!(ExternalSourceOperationError::decode(&encoded), Some(error)); + assert!(!encoded.contains("metadata")); +} + +#[test] +fn control_action_uses_one_camel_case_dto_across_product_surfaces() { + let request = ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: "surface-operation-1".to_string(), + expected_preference_revision: Some(8), + action: ExternalSourceControlActionV1::SetSourceEnabled { + source_key: "opencode.commands:project".to_string(), + enabled: false, + }, + }; + + let encoded = serde_json::to_value(&request).expect("serialize control request"); + assert_eq!(encoded["schemaVersion"], 1); + assert_eq!(encoded["operationId"], "surface-operation-1"); + assert_eq!(encoded["expectedPreferenceRevision"], 8); + assert_eq!(encoded["action"]["type"], "set_source_enabled"); + assert_eq!(encoded["action"]["sourceKey"], "opencode.commands:project"); + assert!(encoded["action"].get("source_key").is_none()); + assert_eq!( + serde_json::from_value::(encoded) + .expect("deserialize the shared control request"), + request + ); +} + +#[test] +fn legacy_operation_errors_decode_with_empty_extension_fields() { + let decoded = ExternalSourceOperationError::decode( + r#"{"code":"unavailable","detail":"retry","retryable":true}"#, + ) + .expect("legacy operation error remains readable"); + + assert_eq!(decoded.code, ExternalSourceOperationErrorCode::Unavailable); + assert!(decoded.stage.is_none()); + assert!(decoded.causation_id.is_none()); + assert!(decoded.recovery_actions.is_empty()); +} + +#[test] +fn decoded_operation_errors_bound_untrusted_extension_fields() { + let oversized = "x".repeat(5000); + let encoded = serde_json::json!({ + "code": "stale_revision", + "detail": oversized, + "retryable": true, + "correlationId": "forged\nreference", + "recoveryActions": [ + { "type": "refresh" }, + { "type": "refresh" }, + { "type": "retry" } + ] + }) + .to_string(); + + let decoded = ExternalSourceOperationError::decode(&encoded).unwrap(); + assert_eq!(decoded.detail.chars().count(), 4096); + assert!(decoded.correlation_id.is_none()); + assert_eq!( + decoded.recovery_actions, + vec![ + ExternalSourceRecoveryActionV1::Refresh, + ExternalSourceRecoveryActionV1::Retry, + ] + ); +} diff --git a/src/crates/services/AGENTS.md b/src/crates/services/AGENTS.md index 9b11c6c68c..a5b8c5afe0 100644 --- a/src/crates/services/AGENTS.md +++ b/src/crates/services/AGENTS.md @@ -11,7 +11,7 @@ OS/network capabilities. | Crate | Responsibility | Local doc | |---|---|---| -| `services-core` | Reusable local service primitives, filesystem helpers, LSP plugin registry rules, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | +| `services-core` | Reusable local service primitives, managed process-tree lifecycle, filesystem helpers, LSP plugin registry rules, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | Concrete MCP, git, remote, file-watch, MiniApp runtime, review-platform provider service, product-domain port implementations, and platform-neutral Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | | `relay-service` | Reusable Remote Connect relay state, storage, and HTTP/WebSocket routes shared by standalone and embedded hosts | [AGENTS.md](relay-service/AGENTS.md) | | `page-function-runtime` | Embedded JS Page Function runtime (rquickjs) for BitFun Pages | [AGENTS.md](page-function-runtime/AGENTS.md) | diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 1f197f4020..713f10ed04 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -7,7 +7,7 @@ without the full product runtime. This includes generic filesystem/search/JSON IO helpers, LSP package/protocol/watch/process primitives, session metadata storage helpers, and local OS action primitives such as command lookup, clipboard, file/url opening, script execution, workspace runtime FS/shell -providers, and system facts. Product crates may layer routing, policy, +providers, managed process-tree lifecycle, and system facts. Product crates may layer routing, policy, capability selection, event emission, or legacy error mapping outside this crate. @@ -31,6 +31,14 @@ crate. globals, or product runtime bindings to `filesystem`; keep those in core or a reviewed adapter/provider. - Preserve legacy core imports with facade/re-export code when ownership moves. +- `process_tree` is the single reusable owner for supervised child-process + lifecycle. Unix implementations use a dedicated process group; Windows must + attach a suspended child to a kill-on-close Job Object before resuming and + fail closed if attachment fails. Consumers own protocol shutdown; this owner + owns cleanup for managed descendants and does not claim sandbox or + resource-limit safety. Unix descendants that deliberately create a new + session/process group are outside this boundary and must be treated as a + disclosed residual risk until a platform supervisor is introduced. ## Verification diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 7214f70b1e..9d2e8b2362 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -33,7 +33,15 @@ regex = { workspace = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } -windows = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } [features] default = ["lsp"] diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 6f08ec5c33..62255a45f0 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod managed_runtime; pub mod markdown; pub mod persistence; pub mod process_manager; +pub mod process_tree; pub mod session; pub mod session_usage; pub mod storage_cleanup; diff --git a/src/crates/services/services-core/src/process_manager.rs b/src/crates/services/services-core/src/process_manager.rs index 8faab185e3..5f8d7d6ce4 100644 --- a/src/crates/services/services-core/src/process_manager.rs +++ b/src/crates/services/services-core/src/process_manager.rs @@ -1,14 +1,10 @@ //! Unified process management to avoid Windows child process leaks -use std::io; use std::process::Command; use std::sync::LazyLock; #[cfg(target_os = "macos")] use std::sync::OnceLock; -use tokio::process::{Child, Command as TokioCommand}; -#[cfg(unix)] -use tokio::time::timeout; -use tokio::time::Duration; +use tokio::process::Command as TokioCommand; #[cfg(windows)] use log::warn; @@ -166,92 +162,6 @@ fn build_macos_path_env() -> Option { std::env::join_paths(merged).ok() } -#[cfg(unix)] -pub fn configure_process_group(command: &mut TokioCommand) { - command.process_group(0); -} - -#[cfg(not(unix))] -pub fn configure_process_group(_command: &mut TokioCommand) {} - -#[cfg(unix)] -pub async fn terminate_child_process_tree( - child: &mut Child, - graceful_timeout: Duration, -) -> io::Result<()> { - let pid = child.id(); - - if let Some(pid) = pid { - let process_group = format!("-{}", pid); - let _ = create_tokio_command("kill") - .arg("-TERM") - .arg(&process_group) - .status() - .await; - - match timeout(graceful_timeout, child.wait()).await { - Ok(wait_result) => return wait_result.map(|_| ()), - Err(_) => { - let _ = create_tokio_command("kill") - .arg("-KILL") - .arg(&process_group) - .status() - .await; - return child.wait().await.map(|_| ()); - } - } - } - - child.start_kill()?; - child.wait().await.map(|_| ()) -} - -#[cfg(windows)] -pub async fn terminate_child_process_tree( - child: &mut Child, - graceful_timeout: Duration, -) -> io::Result<()> { - let pid = child.id(); - - let _ = graceful_timeout; - - if let Some(pid) = pid { - let _ = create_tokio_command("taskkill") - .arg("/PID") - .arg(pid.to_string()) - .arg("/T") - .arg("/F") - .status() - .await; - return child.wait().await.map(|_| ()); - } - - child.start_kill()?; - child.wait().await.map(|_| ()) -} - -pub fn spawn_child_process_tree_cleanup(child: Child, graceful_timeout: Duration) { - let _ = std::thread::Builder::new() - .name("process-tree-cleanup".to_string()) - .spawn(move || { - match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => { - runtime.block_on(async move { - let mut child = child; - let _ = terminate_child_process_tree(&mut child, graceful_timeout).await; - }); - } - Err(_) => { - let mut child = child; - let _ = child.start_kill(); - } - } - }); -} - pub fn cleanup_all_processes() { GLOBAL_PROCESS_MANAGER.cleanup_all(); } diff --git a/src/crates/services/services-core/src/process_tree.rs b/src/crates/services/services-core/src/process_tree.rs new file mode 100644 index 0000000000..9cc76cd09d --- /dev/null +++ b/src/crates/services/services-core/src/process_tree.rs @@ -0,0 +1,518 @@ +//! Process-tree supervision for executable integration runtimes. +//! +//! This provides failure containment and deterministic cleanup for managed +//! descendants. It is not an OS sandbox and does not establish a trust +//! boundary for child-process IO. On Unix, the boundary is one process group; +//! a program that deliberately creates a new session/process group escapes it. + +use std::fmt; +use std::io; +use std::time::Duration; +#[cfg(unix)] +use std::time::Instant; +use tokio::process::ChildStderr; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CleanupOutcome { + AlreadyExited, + #[cfg(unix)] + Graceful, + Forced, +} + +pub struct ProcessTreeChild { + child: Child, + platform: PlatformProcessTree, +} + +impl fmt::Debug for ProcessTreeChild { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessTreeChild") + .field("process_id", &self.child.id()) + .finish_non_exhaustive() + } +} + +impl ProcessTreeChild { + pub async fn spawn(command: &mut Command) -> io::Result { + command.kill_on_drop(true); + + #[cfg(unix)] + { + command.process_group(0); + let child = command.spawn()?; + let process_group_id = child + .id() + .ok_or_else(|| io::Error::other("spawned process has no process id"))? + as i32; + Ok(Self { + child, + platform: PlatformProcessTree { process_group_id }, + }) + } + + #[cfg(windows)] + { + spawn_windows_process_tree(command).await + } + } + + pub fn take_stdin(&mut self) -> Option { + self.child.stdin.take() + } + + pub fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + pub fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } + + pub fn try_wait(&mut self) -> io::Result> { + self.child.try_wait() + } + + pub async fn wait(&mut self) -> io::Result { + self.child.wait().await + } + + pub async fn terminate(&mut self, grace: Duration) -> io::Result { + let parent_exited = self.child.try_wait()?.is_some(); + + #[cfg(unix)] + { + if parent_exited && !process_group_is_alive(self.platform.process_group_id) { + return Ok(CleanupOutcome::AlreadyExited); + } + terminate_unix_process_tree(&mut self.child, self.platform.process_group_id, grace) + .await + } + + #[cfg(windows)] + { + let _ = grace; + let had_job = self.platform.job.take().is_some(); + if !parent_exited { + self.child.wait().await?; + } + Ok(if had_job { + CleanupOutcome::Forced + } else { + CleanupOutcome::AlreadyExited + }) + } + } + + /// Schedules best-effort graceful cleanup without requiring an async caller. + /// If the cleanup thread or runtime cannot be created, `Drop` still performs + /// immediate process-tree cleanup. + pub fn spawn_cleanup(mut self, grace: Duration) { + let _ = std::thread::Builder::new() + .name("process-tree-cleanup".to_string()) + .spawn(move || { + if let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + let _ = runtime.block_on(self.terminate(grace)); + } + }); + } +} + +impl Drop for ProcessTreeChild { + fn drop(&mut self) { + let parent_exited = self.child.try_wait().is_ok_and(|status| status.is_some()); + + #[cfg(unix)] + { + // SAFETY: negative PID targets the process group created by + // `process_group(0)`; no borrowed memory crosses the FFI boundary. + let _ = unsafe { libc::kill(-self.platform.process_group_id, libc::SIGKILL) }; + } + #[cfg(windows)] + { + self.platform.job.take(); + } + if !parent_exited { + let _ = self.child.start_kill(); + } + } +} + +#[cfg(unix)] +struct PlatformProcessTree { + process_group_id: i32, +} + +#[cfg(unix)] +async fn terminate_unix_process_tree( + child: &mut Child, + process_group_id: i32, + grace: Duration, +) -> io::Result { + signal_process_group(process_group_id, libc::SIGTERM)?; + let deadline = Instant::now() + grace; + loop { + let parent_exited = child.try_wait()?.is_some(); + if !process_group_is_alive(process_group_id) { + if !parent_exited { + child.wait().await?; + } + return Ok(CleanupOutcome::Graceful); + } + if Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + signal_process_group(process_group_id, libc::SIGKILL)?; + child.wait().await?; + Ok(CleanupOutcome::Forced) +} + +#[cfg(unix)] +fn signal_process_group(process_group_id: i32, signal: i32) -> io::Result<()> { + // SAFETY: the integer process-group id was captured from the spawned child + // and the signal value is a libc constant. + let result = unsafe { libc::kill(-process_group_id, signal) }; + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(error) + } +} + +#[cfg(unix)] +fn process_group_is_alive(process_group_id: i32) -> bool { + // SAFETY: signal 0 performs an existence/permission check only. + let result = unsafe { libc::kill(-process_group_id, 0) }; + result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] +struct PlatformProcessTree { + job: Option, +} + +#[cfg(windows)] +async fn spawn_windows_process_tree(command: &mut Command) -> io::Result { + use windows::Win32::System::Threading::CREATE_SUSPENDED; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let job = win32job::Job::create().map_err(job_error)?; + let mut limits = win32job::ExtendedLimitInfo::new(); + limits.limit_kill_on_job_close(); + job.set_extended_limit_info(&limits).map_err(job_error)?; + + command.creation_flags(CREATE_SUSPENDED.0 | CREATE_NO_WINDOW); + let mut child = command.spawn()?; + let attach_result = (|| { + let process_id = child + .id() + .ok_or_else(|| io::Error::other("spawned process has no process id"))?; + let process_handle = child + .raw_handle() + .ok_or_else(|| io::Error::other("spawned process has no process handle"))?; + job.assign_process(process_handle as isize) + .map_err(job_error)?; + resume_primary_thread(process_id) + })(); + + if let Err(error) = attach_result { + drop(job); + let _ = child.kill().await; + return Err(error); + } + + Ok(ProcessTreeChild { + child, + platform: PlatformProcessTree { job: Some(job) }, + }) +} + +#[cfg(windows)] +fn resume_primary_thread(process_id: u32) -> io::Result<()> { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + // SAFETY: all handles are checked, closed on every branch, and the + // THREADENTRY32 size is initialized as required by ToolHelp. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) + .map_err(|error| io::Error::other(error.to_string()))?; + let result = (|| { + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + Thread32First(snapshot, &mut entry) + .map_err(|error| io::Error::other(error.to_string()))?; + loop { + if entry.th32OwnerProcessID == process_id { + let thread = OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) + .map_err(|error| io::Error::other(error.to_string()))?; + let resumed = ResumeThread(thread); + let _ = CloseHandle(thread); + if resumed == u32::MAX { + return Err(io::Error::last_os_error()); + } + return Ok(()); + } + if Thread32Next(snapshot, &mut entry).is_err() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "suspended process primary thread was not found", + )); + } + } + })(); + let _ = CloseHandle(snapshot); + result + } +} + +#[cfg(windows)] +fn job_error(error: impl std::fmt::Display) -> io::Error { + io::Error::other(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{CleanupOutcome, ProcessTreeChild}; + use std::path::Path; + use std::process::Stdio; + use std::time::{Duration, Instant}; + use tokio::process::Command; + + #[tokio::test] + async fn terminate_removes_the_parent_and_its_descendant() { + let temporary = tempfile::tempdir().expect("create process-tree fixture directory"); + let pid_file = temporary.path().join("descendant.pid"); + let mut command = descendant_fixture(&pid_file); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut tree = ProcessTreeChild::spawn(&mut command) + .await + .expect("spawn attached process tree"); + let descendant_pid = wait_for_descendant_pid(&pid_file).await; + assert!(process_is_alive(descendant_pid)); + + let outcome = tree + .terminate(Duration::from_millis(250)) + .await + .expect("terminate process tree"); + + #[cfg(unix)] + assert!(matches!( + outcome, + CleanupOutcome::Graceful | CleanupOutcome::Forced + )); + #[cfg(windows)] + assert_eq!(outcome, CleanupOutcome::Forced); + wait_until_process_exits(descendant_pid).await; + } + + #[tokio::test] + async fn terminate_removes_descendant_after_parent_has_exited() { + let temporary = tempfile::tempdir().expect("create process-tree fixture directory"); + let pid_file = temporary.path().join("orphaned-descendant.pid"); + let mut command = orphaned_descendant_fixture(&pid_file); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut tree = ProcessTreeChild::spawn(&mut command) + .await + .expect("spawn attached process tree"); + let descendant_pid = wait_for_descendant_pid(&pid_file).await; + wait_until_parent_exits(&mut tree).await; + assert!(process_is_alive(descendant_pid)); + + tree.terminate(Duration::from_millis(250)) + .await + .expect("terminate orphaned descendant"); + wait_until_process_exits(descendant_pid).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn detached_unix_descendant_is_explicitly_outside_the_process_group_boundary() { + let temporary = tempfile::tempdir().expect("create detached process fixture directory"); + let pid_file = temporary.path().join("detached-descendant.pid"); + let executable = std::env::current_exe().expect("locate test executable"); + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("\"$BITFUN_PROCESS_TREE_TEST_EXE\" --exact process_tree::tests::unix_detached_fixture_process --nocapture") + .env("BITFUN_PROCESS_TREE_TEST_EXE", executable) + .env("BITFUN_DETACHED_FIXTURE", "1") + .env("BITFUN_DESCENDANT_PID_FILE", &pid_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut tree = ProcessTreeChild::spawn(&mut command) + .await + .expect("spawn process-group fixture"); + let descendant_pid = wait_for_descendant_pid(&pid_file).await; + tree.terminate(Duration::from_millis(100)) + .await + .expect("terminate managed process group"); + + assert!( + process_is_alive(descendant_pid), + "a detached descendant must not be described as managed by the Unix process-group boundary" + ); + // SAFETY: the PID was written by this test fixture and is cleaned up + // immediately so the contract test never leaks the escaped process. + let _ = unsafe { libc::kill(descendant_pid as i32, libc::SIGKILL) }; + wait_until_process_exits(descendant_pid).await; + } + + #[cfg(unix)] + #[test] + fn unix_detached_fixture_process() { + if std::env::var_os("BITFUN_DETACHED_FIXTURE").is_none() { + return; + } + // SAFETY: the fixture is a single-threaded test subprocess created + // solely to verify the documented process-group containment limit. + assert!( + unsafe { libc::setsid() } >= 0, + "fixture must create a new session" + ); + let pid_file = std::env::var("BITFUN_DESCENDANT_PID_FILE").expect("fixture PID file path"); + std::fs::write(pid_file, std::process::id().to_string()) + .expect("publish detached fixture PID"); + loop { + std::thread::sleep(Duration::from_secs(60)); + } + } + + #[cfg(windows)] + fn descendant_fixture(pid_file: &Path) -> Command { + let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:BITFUN_DESCENDANT_PID_FILE, [string]$child.Id); while ($true) { Start-Sleep -Seconds 60 }"#; + let mut command = Command::new("powershell.exe"); + command + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(script) + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + #[cfg(windows)] + fn orphaned_descendant_fixture(pid_file: &Path) -> Command { + let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:BITFUN_DESCENDANT_PID_FILE, [string]$child.Id)"#; + let mut command = Command::new("powershell.exe"); + command + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(script) + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + #[cfg(unix)] + fn descendant_fixture(pid_file: &Path) -> Command { + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("sleep 60 & echo $! > \"$BITFUN_DESCENDANT_PID_FILE\"; wait") + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + #[cfg(unix)] + fn orphaned_descendant_fixture(pid_file: &Path) -> Command { + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("sleep 60 & echo $! > \"$BITFUN_DESCENDANT_PID_FILE\"") + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + async fn wait_until_parent_exits(tree: &mut ProcessTreeChild) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if tree.try_wait().expect("query parent process").is_some() { + return; + } + assert!(Instant::now() < deadline, "parent process did not exit"); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + async fn wait_for_descendant_pid(path: &Path) -> u32 { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(raw) = std::fs::read_to_string(path) { + if let Ok(pid) = raw.trim().parse() { + return pid; + } + } + assert!( + Instant::now() < deadline, + "descendant PID was not published" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + async fn wait_until_process_exits(pid: u32) { + let deadline = Instant::now() + Duration::from_secs(5); + while process_is_alive(pid) { + assert!( + Instant::now() < deadline, + "descendant process survived cleanup" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + #[cfg(windows)] + fn process_is_alive(pid: u32) -> bool { + std::process::Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(format!( + "if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}" + )) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } + + #[cfg(unix)] + fn process_is_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } +} diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 7ebc26da65..23e9e7811e 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -75,8 +75,13 @@ slices that are outside pure product logic but still platform-neutral. decide approval/conflicts, register product tools, or claim OS sandboxing. Approved modules run in target child processes separated from the Rust host for failure containment, not as a security or protocol-authentication boundary. - Target process trees and OS resource containment remain an explicit product - risk until a platform process-tree boundary is implemented. + The shared `services-core::process_tree` boundary owns managed-descendant cleanup for + script workers, local stdio MCP, and other managed service children: Unix uses a dedicated process group; Windows attaches a + suspended child to a kill-on-close Job Object before resuming it and fails + closed when attachment fails. This is lifecycle containment, not an OS + sandbox or a CPU/memory/filesystem/network resource limit; surfaces must keep + those residual risks explicit. Unix descendants that deliberately create a + new session/process group are outside the managed boundary. - Announcement remote fetch/cache lives here; product assembly supplies config values such as endpoint, locale, version, platform, and cache path. - DeepResearch report IO here may own report/citation sidecar filesystem work; diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 1b3eee3548..8328a947fb 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -68,7 +68,12 @@ git2 = { workspace = true, features = ["vendored-openssl"], optional = true } [target.'cfg(windows)'.dependencies] schannel = { version = "0.1", optional = true } -windows = { workspace = true, optional = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows = { workspace = true, optional = true, features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } [features] default = [] @@ -103,6 +108,7 @@ mcp = [ "rmcp", "rmcp/transport-streamable-http-client-reqwest", "sse-stream", + "process-tree", ] miniapp-runtime = [ "base64", @@ -205,7 +211,8 @@ workspace-search = [ "thiserror", "which", ] -script-tool-runtime = ["async-trait", "bitfun-runtime-ports", "which"] +process-tree = ["bitfun-services-core"] +script-tool-runtime = ["async-trait", "bitfun-runtime-ports", "process-tree", "which"] web-tools = ["reqwest", "thiserror"] product-full = [ "announcement", diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 690d46721c..8c74a04cfa 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -39,6 +39,14 @@ pub mod plugin_source; #[cfg(feature = "remote-connect")] pub mod remote_connect; +#[cfg(all(test, feature = "remote-connect"))] +mod feature_contract_tests { + #[test] + fn remote_connect_feature_exposes_its_public_module() { + let _ = super::remote_connect::RemoteConnectSubmissionSource::Relay; + } +} + #[cfg(feature = "remote-ssh")] pub mod remote_ssh; diff --git a/src/crates/services/services-integrations/src/mcp/server/process.rs b/src/crates/services/services-integrations/src/mcp/server/process.rs index b13153ac37..bb61793a9f 100644 --- a/src/crates/services/services-integrations/src/mcp/server/process.rs +++ b/src/crates/services/services-integrations/src/mcp/server/process.rs @@ -8,11 +8,11 @@ use crate::mcp::protocol::{InitializeResult, MCPMessage, MCPServerInfo, MCPTrans use crate::mcp::server::{is_mcp_auth_error_message, merge_mcp_remote_headers}; use crate::mcp::{MCPRuntimeError, MCPRuntimeResult}; use bitfun_services_core::process_manager; +use bitfun_services_core::process_tree::ProcessTreeChild; use log::{debug, error, info, warn}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio::process::Child; use tokio::sync::{mpsc, RwLock}; /// MCP server process. @@ -21,7 +21,7 @@ pub struct MCPServerProcess { name: String, server_type: MCPServerType, status: Arc>, - child: Option, + child: Option, connection: Option>, server_info: Option, start_time: Option, @@ -136,7 +136,7 @@ impl MCPServerProcess { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - let child = cmd.spawn().map_err(|e| { + let child = ProcessTreeChild::spawn(&mut cmd).await.map_err(|e| { error!( "Failed to spawn MCP server process: command={} error={}", final_command, e @@ -156,12 +156,10 @@ impl MCPServerProcess { }; let stdin = child - .stdin - .take() + .take_stdin() .ok_or_else(|| MCPRuntimeError::process("Failed to capture stdin".to_string()))?; let stdout = child - .stdout - .take() + .take_stdout() .ok_or_else(|| MCPRuntimeError::process("Failed to capture stdout".to_string()))?; let (tx, rx) = mpsc::unbounded_channel(); @@ -322,7 +320,7 @@ impl MCPServerProcess { } if let Some(child) = self.child.as_mut() { - if let Err(error) = child.kill().await { + if let Err(error) = child.terminate(Duration::from_millis(500)).await { let message = format!( "Failed to kill MCP server process: name={} id={} error={}", self.name, self.id, error @@ -574,8 +572,6 @@ fn safe_process_environment_keys() -> &'static [&'static str] { impl Drop for MCPServerProcess { fn drop(&mut self) { - if let Some(mut child) = self.child.take() { - let _ = child.start_kill(); - } + self.child.take(); } } diff --git a/src/crates/services/services-integrations/src/script_tool.rs b/src/crates/services/services-integrations/src/script_tool.rs index 99335718eb..4fa3051002 100644 --- a/src/crates/services/services-integrations/src/script_tool.rs +++ b/src/crates/services/services-integrations/src/script_tool.rs @@ -6,6 +6,7 @@ use bitfun_runtime_ports::{ ScriptToolLoadRequest, ScriptToolLoadResponse, ScriptToolRuntime, ScriptToolRuntimeAvailability, }; +use bitfun_services_core::process_tree::ProcessTreeChild; use serde::Deserialize; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -13,8 +14,8 @@ use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{ChildStdin, ChildStdout, Command}; use tokio::sync::{oneshot, watch, Mutex, OwnedSemaphorePermit, RwLock, Semaphore}; const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -427,7 +428,7 @@ async fn read_protocol_frame( struct NodeWorker { stdin: Mutex, - child: Mutex, + child: Mutex, pending: Arc>>, next_request_id: AtomicU64, response_token: String, @@ -447,21 +448,23 @@ impl NodeWorker { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = command.spawn().map_err(|error| { - PortError::new( - PortErrorKind::NotAvailable, - format!("failed to start JavaScript tool worker: {error}"), - ) - })?; + .env_remove("NODE_OPTIONS") + .env_remove("NODE_PATH"); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("failed to start JavaScript tool worker: {error}"), + ) + })?; let stdin = child - .stdin - .take() + .take_stdin() .ok_or_else(|| PortError::new(PortErrorKind::Backend, "worker stdin is unavailable"))?; - let stdout = child.stdout.take().ok_or_else(|| { + let stdout = child.take_stdout().ok_or_else(|| { PortError::new(PortErrorKind::Backend, "worker stdout is unavailable") })?; - let stderr = child.stderr.take().ok_or_else(|| { + let stderr = child.take_stderr().ok_or_else(|| { PortError::new(PortErrorKind::Backend, "worker stderr is unavailable") })?; let mut reader = BufReader::new(stdout); @@ -651,12 +654,16 @@ impl NodeWorker { if matches!(child.try_wait(), Ok(Some(_))) { return Ok(()); } - child.kill().await.map_err(|error| { - PortError::new( - PortErrorKind::Backend, - format!("failed to stop script tool worker: {error}"), - ) - }) + child + .terminate(CANCEL_GRACE_PERIOD) + .await + .map(|_| ()) + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("failed to stop script tool worker: {error}"), + ) + }) } async fn is_running(&self) -> bool { @@ -811,9 +818,12 @@ impl NodeScriptToolRuntime { impl ScriptToolRuntime for NodeScriptToolRuntime { async fn availability(&self) -> ScriptToolRuntimeAvailability { match self.resolve_executable().await { - Some(executable) => ScriptToolRuntimeAvailability::Available { - executable: executable.to_string_lossy().into_owned(), - version: "not checked".to_string(), + Some(executable) => match probe_node_version(&executable).await { + Ok(version) => ScriptToolRuntimeAvailability::Available { + executable: executable.to_string_lossy().into_owned(), + version, + }, + Err(reason) => ScriptToolRuntimeAvailability::Unavailable { reason }, }, None => ScriptToolRuntimeAvailability::Unavailable { reason: "BitFun could not find Node.js for external tools".to_string(), @@ -995,6 +1005,50 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { } } +async fn probe_node_version(executable: &PathBuf) -> Result { + let mut command = Command::new(executable); + command + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .env_remove("NODE_OPTIONS") + .env_remove("NODE_PATH"); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|_| "BitFun could not start Node.js for external tools".to_string())?; + let stdout = child + .take_stdout() + .ok_or_else(|| "Node.js version output was unavailable".to_string())?; + let probe = async move { + let mut output = Vec::with_capacity(65); + stdout + .take(65) + .read_to_end(&mut output) + .await + .map_err(|_| "Node.js version output could not be read".to_string())?; + let status = child + .wait() + .await + .map_err(|_| "Node.js version probe failed".to_string())?; + Ok::<_, String>((status, output)) + }; + let (status, output) = tokio::time::timeout(std::time::Duration::from_secs(2), probe) + .await + .map_err(|_| "Node.js version probe timed out".to_string())??; + if !status.success() { + return Err("Node.js version probe failed".to_string()); + } + let version = String::from_utf8(output) + .map_err(|_| "Node.js returned an invalid version".to_string())? + .trim() + .to_string(); + if version.is_empty() || version.len() > 64 || !version.starts_with('v') { + return Err("Node.js returned an invalid version".to_string()); + } + Ok(version) +} + #[cfg(test)] mod tests { use super::NodeScriptToolRuntime; diff --git a/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs b/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs index 4d93fc9e77..44c81c2c1b 100644 --- a/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs +++ b/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs @@ -9,10 +9,10 @@ use std::{ }; use async_trait::async_trait; -use bitfun_services_core::process_manager; +use bitfun_services_core::{process_manager, process_tree::ProcessTreeChild}; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}, - process::{Child, ChildStderr, ChildStdin, ChildStdout}, + process::{ChildStderr, ChildStdin, ChildStdout}, sync::{mpsc, Mutex}, time::{sleep, timeout}, }; @@ -62,7 +62,7 @@ struct ManagedClientState { #[derive(Debug)] struct AsyncDaemonClient { - child: StdMutex>, + child: StdMutex>, protocol: ProtocolClient, writer_task: StdMutex>>, reader_task: StdMutex>>, @@ -440,16 +440,14 @@ impl AsyncDaemonClient { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - process_manager::configure_process_group(&mut command); - - let mut child = command.spawn()?; - let stdin = child.stdin.take().ok_or_else(|| { + let mut child = ProcessTreeChild::spawn(&mut command).await?; + let stdin = child.take_stdin().ok_or_else(|| { AppError::Protocol("flashgrep stdio backend did not provide stdin".into()) })?; - let stdout = child.stdout.take().ok_or_else(|| { + let stdout = child.take_stdout().ok_or_else(|| { AppError::Protocol("flashgrep stdio backend did not provide stdout".into()) })?; - let stderr = child.stderr.take(); + let stderr = child.take_stderr(); let (protocol, write_rx) = ProtocolClient::channel("flashgrep stdio backend"); @@ -555,11 +553,11 @@ impl AsyncDaemonClient { wait_result?; Ok(()) } - Err(_) => { - process_manager::terminate_child_process_tree(child, Duration::from_millis(750)) - .await - .map_err(AppError::Io) - } + Err(_) => child + .terminate(Duration::from_millis(750)) + .await + .map(|_| ()) + .map_err(AppError::Io), } } @@ -670,7 +668,7 @@ impl AsyncDaemonClient { self.protocol.reject_pending(message.into()).await; } - fn take_child_for_drop(&self) -> Option { + fn take_child_for_drop(&self) -> Option { take_std_option(&self.child) } @@ -692,7 +690,7 @@ impl Drop for AsyncDaemonClient { self.mark_closed(); self.abort_background_tasks_for_drop(); if let Some(child) = self.take_child_for_drop() { - process_manager::spawn_child_process_tree_cleanup(child, DROP_CLEANUP_TIMEOUT); + child.spawn_cleanup(DROP_CLEANUP_TIMEOUT); } } } diff --git a/src/crates/services/services-integrations/tests/script_tool_runtime.rs b/src/crates/services/services-integrations/tests/script_tool_runtime.rs index d47a500988..0577af9e01 100644 --- a/src/crates/services/services-integrations/tests/script_tool_runtime.rs +++ b/src/crates/services/services-integrations/tests/script_tool_runtime.rs @@ -59,11 +59,15 @@ fn named_invoke_request(operation_id: &str, revision: &str) -> ScriptToolInvokeR } #[tokio::test] -async fn runtime_availability_does_not_claim_an_unchecked_node_version() { +async fn runtime_availability_reports_the_probed_node_version() { let runtime = NodeScriptToolRuntime::discover(); if let ScriptToolRuntimeAvailability::Available { version, .. } = runtime.availability().await { - assert_eq!(version, "not checked"); + assert!( + version.starts_with('v'), + "unexpected Node.js version: {version}" + ); + assert!(version[1..].split('.').count() >= 2); } } diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts index 660e0d8f24..3c991d01aa 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -11,6 +11,19 @@ interface PendingRequest { timeout: NodeJS.Timeout; } +export function webSocketResponseError(value: unknown): Error { + const record = value && typeof value === 'object' + ? value as { code?: unknown; message?: unknown; data?: unknown } + : undefined; + const message = typeof record?.message === 'string' + ? record.message + : String(value); + const error = new Error(message) as Error & { code?: unknown; data?: unknown }; + error.code = record?.code; + error.data = record?.data; + return error; +} + export class WebSocketTransportAdapter implements ITransportAdapter { private ws: WebSocket | null = null; private url: string; @@ -120,10 +133,7 @@ export class WebSocketTransportAdapter implements ITransportAdapter { clearTimeout(pending.timeout); if (message.error) { - const errorMsg = typeof message.error === 'object' && message.error.message - ? message.error.message - : String(message.error); - pending.reject(new Error(errorMsg)); + pending.reject(webSocketResponseError(message.error)); } else { pending.resolve(message.result); } diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts index e27641d659..f57730dad9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts @@ -85,6 +85,62 @@ describe('ApiClient startup trace classification', () => { })); }); + it('preserves structured transport error facts across command wrapping', async () => { + const transportError = Object.assign(new Error('Unknown Server Host operation'), { + code: -32004, + data: { + code: 'host_capability_unavailable', + detail: 'Unknown Server Host operation', + retryable: false, + stage: 'execute_remote', + correlationId: 'server-legacy-1', + recoveryActions: [{ type: 'reconnect_host' }], + }, + }); + adapterMocks.request.mockRejectedValueOnce(transportError); + const client = new ApiClient({ enableLogging: false, retries: 0 }); + + const error = await client.invoke('get_external_source_control_snapshot', { + request: { workspacePath: 'D:/workspace/BitFun' }, + }).catch((caught: unknown) => caught as { + code: string; + details?: { originalError?: unknown }; + }); + + expect(error).toMatchObject({ + code: 'COMMAND_FAILED', + details: { + originalError: { + code: -32004, + data: { + code: 'host_capability_unavailable', + correlationId: 'server-legacy-1', + recoveryActions: [{ type: 'reconnect_host' }], + }, + }, + }, + }); + }); + + it('keeps message-only transport errors parseable by domain adapters', async () => { + const encoded = JSON.stringify({ + code: 'stale_revision', + detail: 'Refresh and try again', + retryable: true, + recoveryActions: [{ type: 'refresh' }], + }); + adapterMocks.request.mockRejectedValueOnce(new Error(encoded)); + const client = new ApiClient({ enableLogging: false, retries: 0 }); + + const error = await client.invoke('apply_external_source_control_action_command', { + request: {}, + }).catch((caught: unknown) => caught as { + details?: { originalError?: unknown }; + }); + + expect(error.details?.originalError).toBe(encoded); + }); + it('uses a bounded response estimate cap for session view restore when perf trace is enabled', async () => { globalThis.__BITFUN_PERF_TRACE_ENABLED__ = true; adapterMocks.request.mockResolvedValueOnce({ turns: [] }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts index 345d4dd4dc..1a6da78d55 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts @@ -34,6 +34,21 @@ function shouldEstimateApiPayloadBytes(): boolean { return globalThis.__BITFUN_PERF_TRACE_ENABLED__ === true; } +function apiErrorCause(error: unknown): unknown { + if (!(error instanceof Error)) { + return error; + } + const transportError = error as Error & { code?: unknown; data?: unknown }; + if (transportError.code === undefined && transportError.data === undefined) { + return error.message; + } + return { + message: error.message, + ...(transportError.code !== undefined ? { code: transportError.code } : {}), + ...(transportError.data !== undefined ? { data: transportError.data } : {}), + }; +} + function isOptionalConfigNotFoundCommand(config: TauriCommandConfig, error: unknown): boolean { if (config.command !== 'get_config') { return false; @@ -517,7 +532,7 @@ export class ApiClient implements IApiClient { if (originalError) { apiError.details = { - originalError: originalError.message || originalError, + originalError: apiErrorCause(originalError), stack: originalError.stack }; } diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index 38f7e228cb..b6f60931e3 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -1,24 +1,74 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ExternalSourceApiError, externalSourcesAPI } from './ExternalSourcesAPI'; +import { webSocketResponseError } from '../adapters/websocket-adapter'; +import { PeerProductCommandError } from '../adapters/peer-device-adapter'; +import { ApiClient } from './ApiClient'; const invokeMock = vi.hoisted(() => vi.fn()); +const adapterMocks = vi.hoisted(() => ({ + request: vi.fn(), + listen: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(() => true), +})); -vi.mock('./ApiClient', () => ({ - api: { - invoke: invokeMock, - }, +function surface(catalog: Record) { + const generation = typeof catalog.generation === 'number' ? catalog.generation : 0; + return { + control: { + schemaVersion: 1, + executionDomainId: 'local-user', + refreshGeneration: generation, + preferenceRevision: typeof catalog.preferenceRevision === 'number' + ? catalog.preferenceRevision + : 0, + safeMode: false, + hostCapabilities: { + canRefresh: true, + canMutatePolicy: true, + canManageSources: true, + canApproveRuntime: true, + canExecuteExternalAssets: true, + canSetSafeMode: true, + canRevealSourceLocation: true, + }, + sources: [], + capabilities: [], + diagnostics: [], + recoveryActions: [], + }, + catalog, + }; +} + +vi.mock('../adapters', async importOriginal => ({ + ...await importOriginal(), + getTransportAdapter: () => adapterMocks, })); +vi.mock('./ApiClient', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + invoke: invokeMock, + }, + }; +}); + describe('ExternalSourcesAPI', () => { beforeEach(() => { - invokeMock.mockReset(); - invokeMock.mockResolvedValue({}); + vi.clearAllMocks(); + invokeMock.mockResolvedValue(surface({})); + adapterMocks.isConnected.mockReturnValue(true); }); it('keeps workspace ownership and refresh intent in the public snapshot request', async () => { await externalSourcesAPI.getSnapshot('D:/workspace/project', true); - expect(invokeMock).toHaveBeenCalledWith('get_external_source_snapshot', { + expect(invokeMock).toHaveBeenCalledWith('get_external_source_control_snapshot', { request: { workspacePath: 'D:/workspace/project', forceRefresh: true, @@ -29,7 +79,7 @@ describe('ExternalSourcesAPI', () => { it('treats an empty workspace path as the global scope', async () => { await externalSourcesAPI.getSnapshot('', false); - expect(invokeMock).toHaveBeenCalledWith('get_external_source_snapshot', { + expect(invokeMock).toHaveBeenCalledWith('get_external_source_control_snapshot', { request: { workspacePath: undefined, forceRefresh: false, @@ -51,6 +101,157 @@ describe('ExternalSourcesAPI', () => { }); }); + it('falls back to the legacy read path for an older Peer Host', async () => { + invokeMock + .mockRejectedValueOnce( + "command 'get_external_source_control_snapshot' is not supported on CLI peer host", + ) + .mockResolvedValueOnce({ + generation: 3, + discoveryPending: false, + preferenceRevision: 2, + hostCapabilities: { + canRefresh: true, + canMutatePolicy: true, + canManageSources: true, + canApproveRuntime: true, + canExecuteExternalAssets: true, + canSetSafeMode: false, + }, + sources: [], + commands: [], + }); + + const result = await externalSourcesAPI.getSnapshot('D:/workspace/project'); + + expect(invokeMock).toHaveBeenNthCalledWith(2, 'get_external_source_snapshot', { + request: { + workspacePath: 'D:/workspace/project', + forceRefresh: false, + }, + }); + expect(result.control).toMatchObject({ + executionDomainId: 'legacy-host', + safeMode: false, + hostCapabilities: { + canManageSources: true, + canSetSafeMode: false, + canRevealSourceLocation: false, + }, + recoveryActions: [{ type: 'reconnect_host' }], + }); + }); + + it('accepts a complete older Host surface when both capability copies omit additive fields', async () => { + const legacyCapabilities = { + canRefresh: true, + canMutatePolicy: true, + canManageSources: true, + canApproveRuntime: true, + canExecuteExternalAssets: true, + canSetSafeMode: true, + }; + invokeMock.mockResolvedValue({ + control: { + ...surface({}).control, + hostCapabilities: legacyCapabilities, + }, + catalog: { + generation: 0, + discoveryPending: false, + preferenceRevision: 0, + hostCapabilities: legacyCapabilities, + sources: [], + commands: [], + }, + }); + + const result = await externalSourcesAPI.getSnapshot(); + + expect(result.hostCapabilities.canRevealSourceLocation).toBe(false); + expect(result.control?.hostCapabilities.canRevealSourceLocation).toBe(false); + }); + + it('preserves a legacy Server Host read-only boundary through ApiClient wrapping', async () => { + const client = new ApiClient({ enableLogging: false, retries: 0 }); + adapterMocks.request.mockRejectedValueOnce(webSocketResponseError({ + code: -32004, + message: 'Unknown Server Host operation', + data: { + code: 'host_capability_unavailable', + detail: 'Unknown Server Host operation', + retryable: false, + stage: 'execute_remote', + correlationId: 'server-legacy-1', + recoveryActions: [{ type: 'reconnect_host' }], + }, + })); + invokeMock + .mockImplementationOnce((command, args) => client.invoke(command, args)) + .mockResolvedValueOnce({ + generation: 3, + discoveryPending: false, + preferenceRevision: 2, + hostCapabilities: { + canRefresh: false, + canMutatePolicy: false, + canManageSources: false, + canApproveRuntime: false, + canExecuteExternalAssets: false, + canSetSafeMode: false, + }, + sources: [], + commands: [], + }); + + const result = await externalSourcesAPI.getSnapshot('D:/workspace/project'); + + expect(result.control.hostCapabilities).toEqual({ + canRefresh: false, + canMutatePolicy: false, + canManageSources: false, + canApproveRuntime: false, + canExecuteExternalAssets: false, + canSetSafeMode: false, + canRevealSourceLocation: false, + }); + expect(result.hostCapabilities).toEqual(result.control.hostCapabilities); + }); + + it('uses the legacy source mutation when an older Peer Host lacks control actions', async () => { + invokeMock + .mockRejectedValueOnce( + "command 'apply_external_source_control_action_command' is not supported on CLI peer host", + ) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce( + "command 'get_external_source_control_snapshot' is not supported on CLI peer host", + ) + .mockResolvedValueOnce({ + generation: 4, + discoveryPending: false, + preferenceRevision: 3, + sources: [], + commands: [], + }); + + await externalSourcesAPI.setSourceEnabled( + 'D:/workspace/project', + 'opencode.commands:project', + false, + 2, + ); + + expect(invokeMock).toHaveBeenNthCalledWith(2, 'set_external_source_enabled_command', { + request: { + workspacePath: 'D:/workspace/project', + sourceKey: 'opencode.commands:project', + enabled: false, + expectedPreferenceRevision: 2, + }, + }); + }); + it('sends policy scope and optimistic revision as one atomic mutation', async () => { await externalSourcesAPI.updateIntegrationPolicy('D:/workspace/project', { expectedPreferenceRevision: 8, @@ -101,12 +302,12 @@ describe('ExternalSourcesAPI', () => { }); it('fails closed when a legacy host omits capabilities and policy', async () => { - invokeMock.mockResolvedValue({ + invokeMock.mockResolvedValue(surface({ generation: 1, discoveryPending: false, sources: [], commands: [], - }); + })); const result = await externalSourcesAPI.getSnapshot(); @@ -116,6 +317,8 @@ describe('ExternalSourcesAPI', () => { canManageSources: false, canApproveRuntime: false, canExecuteExternalAssets: false, + canSetSafeMode: false, + canRevealSourceLocation: false, }); expect(result.integrationPolicy).toMatchObject({ status: 'unknown', @@ -127,7 +330,7 @@ describe('ExternalSourcesAPI', () => { }); it('keeps an incompatible schema identifiable while projecting it safely off', async () => { - invokeMock.mockResolvedValue({ + invokeMock.mockResolvedValue(surface({ generation: 1, discoveryPending: false, sources: [], @@ -137,7 +340,7 @@ describe('ExternalSourcesAPI', () => { status: 'incompatible_schema', futurePolicyField: { doNotExpose: true }, }, - }); + })); const result = await externalSourcesAPI.getSnapshot(); @@ -150,7 +353,12 @@ describe('ExternalSourcesAPI', () => { }); it('normalizes partial snapshots returned from mutations too', async () => { - invokeMock.mockResolvedValue({ generation: 2, discoveryPending: false, sources: [], commands: [] }); + invokeMock.mockResolvedValue(surface({ + generation: 2, + discoveryPending: false, + sources: [], + commands: [], + })); const result = await externalSourcesAPI.setSourceEnabled( 'D:/workspace/project', @@ -162,10 +370,28 @@ describe('ExternalSourcesAPI', () => { expect(result.hostCapabilities.canManageSources).toBe(false); expect(result.integrationPolicy.status).toBe('unknown'); expect(result.integrationPolicy.effective.enabled).toBe(false); + expect(invokeMock).toHaveBeenCalledWith( + 'apply_external_source_control_action_command', + { + request: { + workspacePath: 'D:/workspace/project', + control: { + schemaVersion: 1, + operationId: expect.any(String), + expectedPreferenceRevision: 4, + action: { + type: 'set_source_enabled', + sourceKey: 'opencode:project', + enabled: false, + }, + }, + }, + }, + ); }); it('restores omitted empty MCP collections at the API boundary', async () => { - invokeMock.mockResolvedValue({ + invokeMock.mockResolvedValue(surface({ generation: 3, discoveryPending: false, sources: [{ @@ -202,7 +428,7 @@ describe('ExternalSourcesAPI', () => { staticStatus: { state: 'ready' }, }, }], - }); + })); const result = await externalSourcesAPI.getSnapshot(); @@ -220,20 +446,20 @@ describe('ExternalSourcesAPI', () => { }); it('rejects non-array collection fields instead of presenting them as empty', async () => { - invokeMock.mockResolvedValue({ + invokeMock.mockResolvedValue(surface({ generation: 4, discoveryPending: false, sources: [], commands: [], mcpServers: 'not-an-array', - }); + })); await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ code: 'internal', retryable: true, }); - invokeMock.mockResolvedValue({ + invokeMock.mockResolvedValue(surface({ generation: 5, discoveryPending: false, sources: [], @@ -257,11 +483,143 @@ describe('ExternalSourcesAPI', () => { staticStatus: { state: 'ready' }, }, }], - }); + })); await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ code: 'internal', retryable: true, }); }); + + it('preserves structured error facts for consistent recovery UX', async () => { + invokeMock.mockRejectedValue(JSON.stringify({ + code: 'runtime_unavailable', + detail: 'Node.js is not available', + retryable: false, + correlationId: 'correlation-1', + causationId: 'refresh-8', + stage: 'activate_runtime', + recoveryActions: [{ type: 'install_runtime' }, { type: 'refresh' }], + })); + + await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ + code: 'runtime_unavailable', + correlationId: 'correlation-1', + causationId: 'refresh-8', + stage: 'activate_runtime', + recoveryActions: [{ type: 'install_runtime' }, { type: 'refresh' }], + }); + }); + + it('fails closed when the shared control projection contains malformed recovery actions', async () => { + const malformed = surface({ + generation: 8, + discoveryPending: false, + sources: [], + commands: [], + }); + malformed.control.recoveryActions = [{ type: 'run_arbitrary_command' }]; + invokeMock.mockResolvedValue(malformed); + + await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ + code: 'invalid_response', + retryable: false, + }); + }); + + it('fails closed when v1 host capability facts are malformed or inconsistent', async () => { + const malformed = surface({ + generation: 9, + discoveryPending: false, + sources: [], + commands: [], + }); + malformed.control.hostCapabilities.canSetSafeMode = 'yes' as unknown as boolean; + invokeMock.mockResolvedValue(malformed); + + await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ + code: 'invalid_response', + retryable: false, + }); + + const inconsistent = surface({ + generation: 10, + discoveryPending: false, + sources: [], + commands: [], + hostCapabilities: { + canRefresh: true, + canMutatePolicy: true, + canManageSources: true, + canApproveRuntime: true, + canExecuteExternalAssets: true, + canSetSafeMode: false, + }, + }); + invokeMock.mockResolvedValue(inconsistent); + + await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ + code: 'invalid_response', + retryable: false, + }); + + const stalePreferences = surface({ + generation: 11, + preferenceRevision: 3, + discoveryPending: false, + sources: [], + commands: [], + }); + stalePreferences.control.preferenceRevision = 2; + invokeMock.mockResolvedValue(stalePreferences); + + await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ + code: 'invalid_response', + retryable: true, + }); + }); + + it('preserves Peer Host typed recovery through ApiClient wrapping', async () => { + const client = new ApiClient({ enableLogging: false, retries: 0 }); + adapterMocks.request.mockRejectedValueOnce(new PeerProductCommandError(JSON.stringify({ + code: 'stale_revision', + detail: 'Refresh and try again', + retryable: true, + stage: 'apply_preference', + causationId: 'peer-preference-8', + recoveryActions: [{ type: 'refresh' }], + }))); + invokeMock.mockImplementationOnce((command, args) => client.invoke(command, args)); + + await expect(externalSourcesAPI.setSafeMode( + 'D:/workspace/project', + true, + 8, + )).rejects.toMatchObject({ + code: 'stale_revision', + stage: 'apply_preference', + causationId: 'peer-preference-8', + recoveryActions: [{ type: 'refresh' }], + }); + }); + + it('bounds untrusted legacy error references before they reach a product surface', async () => { + invokeMock.mockRejectedValue(JSON.stringify({ + code: 'stale_revision', + detail: `retry\n${'x'.repeat(5000)}`, + retryable: true, + correlationId: 'forged\nreference', + recoveryActions: [{ type: 'refresh' }], + })); + + await expect(externalSourcesAPI.getSnapshot()).rejects.toMatchObject({ + code: 'stale_revision', + correlationId: undefined, + recoveryActions: [{ type: 'refresh' }], + }); + await externalSourcesAPI.getSnapshot().catch((error: ExternalSourceApiError) => { + expect(Array.from(error.message)).toHaveLength(4096); + expect(error.message).not.toContain('\n'); + }); + }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index c06b45ec63..5fab7e3122 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -128,6 +128,8 @@ export interface ExternalSourceCatalogSnapshot { canManageSources: boolean; canApproveRuntime: boolean; canExecuteExternalAssets: boolean; + canSetSafeMode: boolean; + canRevealSourceLocation: boolean; }; generation: number; discoveryPending: boolean; @@ -184,6 +186,8 @@ export interface ExternalSourceCatalogSnapshot { code: string; message: string; }>; + /** Frontend view of the atomic control+catalog response. Legacy hosts omit it. */ + control?: ExternalSourceControlSnapshot; } export type ExternalSubagentActivation = @@ -229,6 +233,7 @@ export interface ExternalSubagentConflict { export type ExternalToolCapability = 'file_system' | 'network' | 'process' | 'environment'; export type ExternalToolActivation = | { state: 'approval_required' } + | { state: 'declined' } | { state: 'disabled' } | { state: 'active' } | { state: 'conflict' } @@ -364,16 +369,119 @@ export interface ExternalMcpConflict { }>; } +export type ExternalSourceOperationStage = + | 'validate_request' + | 'discover' + | 'reconcile' + | 'apply_preference' + | 'activate_runtime' + | 'project_response' + | 'execute_remote'; + +export type ExternalSourceRecoveryActionType = + | 'refresh' + | 'retry' + | 'review' + | 'resolve_conflict' + | 'install_runtime' + | 'reconnect_host' + | 'exit_safe_mode'; + +export interface ExternalSourceRecoveryAction { + type: ExternalSourceRecoveryActionType; +} + +export type ExternalSourceControlAction = + | { type: 'refresh' } + | { type: 'set_source_enabled'; sourceKey: string; enabled: boolean } + | { type: 'set_safe_mode'; enabled: boolean }; + +export interface ExternalSourceControlRequest { + schemaVersion: 1; + operationId: string; + expectedPreferenceRevision?: number; + action: ExternalSourceControlAction; +} + +export type ExternalSourceRuntimeState = + | 'not_applicable' + | 'inactive' + | 'starting' + | 'active' + | 'degraded' + | 'quarantined' + | 'unsupported'; + +export interface ExternalSourceControlSnapshot { + schemaVersion: 1; + executionDomainId: string; + refreshGeneration: number; + preferenceRevision: number; + safeMode: boolean; + hostCapabilities: ExternalSourceCatalogSnapshot['hostCapabilities']; + sources: Array<{ + stableKey: string; + ecosystemId: string; + displayName: string; + scope: ExternalSourceScope; + contentVersion: string; + discovery: 'pending' | 'current' | 'last_known_good' | 'failed' | 'removed'; + desired: 'enabled' | 'disabled'; + review: + | { state: 'not_required' } + | { state: 'required'; contentVersion: string }; + runtime: ExternalSourceRuntimeState; + support: 'supported' | 'partial' | 'unsupported' | 'unavailable'; + effectiveStatus: + | 'discovering' + | 'disabled' + | 'review_required' + | 'conflict' + | 'active' + | 'degraded' + | 'unsupported' + | 'available' + | 'removed'; + }>; + capabilities: Array<{ + kind: 'command' | 'tool' | 'subagent' | 'mcp'; + revision: number; + itemCount: number; + pendingReviewCount: number; + unresolvedConflictCount: number; + runtime: ExternalSourceRuntimeState; + support: 'supported' | 'partial' | 'unsupported' | 'unavailable'; + }>; + diagnostics: NonNullable; + recoveryActions: ExternalSourceRecoveryAction[]; +} + +export interface ExternalSourceSurfaceSnapshot { + control: ExternalSourceControlSnapshot; + catalog: ExternalSourceCatalogSnapshot; +} + export type ExternalSourceOperationErrorCode = | 'invalid_request' | 'host_unavailable' | 'host_capability_unavailable' + | 'trust_required' | 'policy_incompatible' | 'policy_limited' | 'stale_revision' | 'conflict' | 'not_found' | 'unavailable' + | 'runtime_unavailable' + | 'unsupported' + | 'incompatible_version' + | 'dependency_failed' + | 'timeout' + | 'cancelled' + | 'overloaded' + | 'process_lost' + | 'invalid_response' + | 'temporarily_unavailable' | 'internal'; export class ExternalSourceApiError extends Error { @@ -382,6 +490,9 @@ export class ExternalSourceApiError extends Error { public readonly detail: string, public readonly retryable: boolean, public readonly correlationId?: string, + public readonly causationId?: string, + public readonly stage?: ExternalSourceOperationStage, + public readonly recoveryActions: ExternalSourceRecoveryAction[] = [], ) { super(detail); this.name = 'ExternalSourceApiError'; @@ -394,8 +505,147 @@ const READ_ONLY_HOST_CAPABILITIES: ExternalSourceCatalogSnapshot['hostCapabiliti canManageSources: false, canApproveRuntime: false, canExecuteExternalAssets: false, + canSetSafeMode: false, + canRevealSourceLocation: false, }; +const CONTROL_DISCOVERY_STATES = new Set(['pending', 'current', 'last_known_good', 'failed', 'removed']); +const CONTROL_DESIRED_STATES = new Set(['enabled', 'disabled']); +const CONTROL_RUNTIME_STATES = new Set([ + 'not_applicable', 'inactive', 'starting', 'active', 'degraded', 'quarantined', 'unsupported', +]); +const CONTROL_SUPPORT_STATES = new Set(['supported', 'partial', 'unsupported', 'unavailable']); +const CONTROL_EFFECTIVE_STATUSES = new Set([ + 'discovering', 'disabled', 'review_required', 'conflict', 'active', 'degraded', 'unsupported', 'available', 'removed', +]); +const CONTROL_CAPABILITY_KINDS = new Set(['command', 'tool', 'subagent', 'mcp']); +const EXTERNAL_SOURCE_SCOPES = new Set([ + 'user_global', 'project', 'workspace_local', 'remote_user', 'remote_project', +]); +const OPERATION_STAGES = new Set([ + 'validate_request', 'discover', 'reconcile', 'apply_preference', 'activate_runtime', 'project_response', 'execute_remote', +]); +const RECOVERY_ACTION_TYPES = new Set([ + 'refresh', 'retry', 'review', 'resolve_conflict', 'install_runtime', 'reconnect_host', 'exit_safe_mode', +]); +const HOST_CAPABILITY_KEYS = [ + 'canRefresh', + 'canMutatePolicy', + 'canManageSources', + 'canApproveRuntime', + 'canExecuteExternalAssets', + 'canSetSafeMode', + 'canRevealSourceLocation', +] as const; +const REQUIRED_HOST_CAPABILITY_KEYS = HOST_CAPABILITY_KEYS.filter( + (key) => key !== 'canRevealSourceLocation', +); + +function isOneOf(value: unknown, values: ReadonlySet): value is T { + return typeof value === 'string' && values.has(value as T); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isHostCapabilities( + value: unknown, +): value is ExternalSourceCatalogSnapshot['hostCapabilities'] { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return Object.keys(record).every( + (key) => (HOST_CAPABILITY_KEYS as readonly string[]).includes(key), + ) + && REQUIRED_HOST_CAPABILITY_KEYS.every((key) => typeof record[key] === 'boolean') + && (record.canRevealSourceLocation === undefined + || typeof record.canRevealSourceLocation === 'boolean'); +} + +function hostCapabilitiesEqual( + left: ExternalSourceCatalogSnapshot['hostCapabilities'], + right: ExternalSourceCatalogSnapshot['hostCapabilities'], +): boolean { + return HOST_CAPABILITY_KEYS.every((key) => left[key] === right[key]); +} + +function normalizeHostCapabilities( + value: unknown, +): ExternalSourceCatalogSnapshot['hostCapabilities'] { + const capabilities = value && typeof value === 'object' + ? value as Partial + : undefined; + return { + ...READ_ONLY_HOST_CAPABILITIES, + canRefresh: capabilities?.canRefresh === true, + canMutatePolicy: capabilities?.canMutatePolicy === true, + canManageSources: capabilities?.canManageSources === true, + canApproveRuntime: capabilities?.canApproveRuntime === true, + canExecuteExternalAssets: capabilities?.canExecuteExternalAssets === true, + canSetSafeMode: capabilities?.canSetSafeMode === true, + canRevealSourceLocation: capabilities?.canRevealSourceLocation === true, + }; +} + +function isRecoveryAction(value: unknown): value is ExternalSourceRecoveryAction { + return Boolean(value) + && typeof value === 'object' + && isOneOf((value as { type?: unknown }).type, RECOVERY_ACTION_TYPES); +} + +function normalizeRecoveryActions(value: unknown, strict: boolean): ExternalSourceRecoveryAction[] { + const actions = normalizeOptionalArray(value); + if (strict && !actions.every(isRecoveryAction)) { + throw new ExternalSourceApiError( + 'invalid_response', + 'External source recovery actions were invalid', + false, + ); + } + return actions.filter(isRecoveryAction); +} + +function isControlReview(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const review = value as Record; + switch (review.state) { + case 'not_required': + return true; + case 'required': + return typeof review.contentVersion === 'string'; + default: + return false; + } +} + +function isControlSource(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const source = value as Record; + return typeof source.stableKey === 'string' + && typeof source.ecosystemId === 'string' + && typeof source.displayName === 'string' + && isOneOf(source.scope, EXTERNAL_SOURCE_SCOPES) + && typeof source.contentVersion === 'string' + && isOneOf(source.discovery, CONTROL_DISCOVERY_STATES) + && isOneOf(source.desired, CONTROL_DESIRED_STATES) + && isControlReview(source.review) + && isOneOf(source.runtime, CONTROL_RUNTIME_STATES) + && isOneOf(source.support, CONTROL_SUPPORT_STATES) + && isOneOf(source.effectiveStatus, CONTROL_EFFECTIVE_STATUSES); +} + +function isCapabilityControl(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const capability = value as Record; + return isOneOf(capability.kind, CONTROL_CAPABILITY_KINDS) + && isNonNegativeInteger(capability.revision) + && isNonNegativeInteger(capability.itemCount) + && isNonNegativeInteger(capability.pendingReviewCount) + && isNonNegativeInteger(capability.unresolvedConflictCount) + && isOneOf(capability.runtime, CONTROL_RUNTIME_STATES) + && isOneOf(capability.support, CONTROL_SUPPORT_STATES); +} + function safePolicySnapshot( status: ExternalIntegrationPolicySnapshot['status'] = 'unknown', schemaMajor = 0, @@ -533,31 +783,119 @@ function normalizeSnapshot(value: unknown): ExternalSourceCatalogSnapshot { })), pendingSubagentApprovals: normalizeOptionalArray(candidate.pendingSubagentApprovals), diagnostics: normalizeOptionalArray(candidate.diagnostics), - hostCapabilities: { - ...READ_ONLY_HOST_CAPABILITIES, - canRefresh: capabilities?.canRefresh === true, - canMutatePolicy: capabilities?.canMutatePolicy === true, - canManageSources: capabilities?.canManageSources === true, - canApproveRuntime: capabilities?.canApproveRuntime === true, - canExecuteExternalAssets: capabilities?.canExecuteExternalAssets === true, - }, + hostCapabilities: normalizeHostCapabilities(capabilities), integrationPolicy: normalizePolicySnapshot(candidate.integrationPolicy), }; } +function normalizeControlSnapshot(value: unknown): ExternalSourceControlSnapshot { + if (!value || typeof value !== 'object') { + throw new ExternalSourceApiError('invalid_response', 'External source control response was not usable', true); + } + const candidate = value as Partial; + if (candidate.schemaVersion !== 1 + || typeof candidate.executionDomainId !== 'string' + || !isNonNegativeInteger(candidate.refreshGeneration) + || !isNonNegativeInteger(candidate.preferenceRevision) + || typeof candidate.safeMode !== 'boolean' + || !isHostCapabilities(candidate.hostCapabilities) + || !Array.isArray(candidate.sources) + || !Array.isArray(candidate.capabilities) + || !candidate.sources.every(isControlSource) + || !candidate.capabilities.every(isCapabilityControl)) { + throw new ExternalSourceApiError('invalid_response', 'External source control schema was invalid', false); + } + return { + ...candidate, + hostCapabilities: normalizeHostCapabilities(candidate.hostCapabilities), + diagnostics: normalizeOptionalArray(candidate.diagnostics), + recoveryActions: normalizeRecoveryActions(candidate.recoveryActions, true), + } as ExternalSourceControlSnapshot; +} + +function normalizeSurfaceSnapshot(value: unknown): ExternalSourceSurfaceSnapshot { + if (!value || typeof value !== 'object') { + throw new ExternalSourceApiError('invalid_response', 'External source surface response was not usable', true); + } + const candidate = value as Partial; + const control = normalizeControlSnapshot(candidate.control); + const rawCatalog = candidate.catalog; + const catalog = normalizeSnapshot(candidate.catalog); + if (catalog.generation !== control.refreshGeneration) { + throw new ExternalSourceApiError( + 'invalid_response', + 'External source control and catalog generations did not match', + true, + ); + } + if ((catalog.preferenceRevision ?? 0) !== control.preferenceRevision) { + throw new ExternalSourceApiError( + 'invalid_response', + 'External source control and catalog preference revisions did not match', + true, + ); + } + if (rawCatalog && typeof rawCatalog === 'object' + && Object.prototype.hasOwnProperty.call(rawCatalog, 'hostCapabilities')) { + const rawCapabilities = (rawCatalog as { hostCapabilities?: unknown }).hostCapabilities; + if (!isHostCapabilities(rawCapabilities) + || !hostCapabilitiesEqual( + control.hostCapabilities, + normalizeHostCapabilities(rawCapabilities), + )) { + throw new ExternalSourceApiError( + 'invalid_response', + 'External source control and catalog Host capabilities did not match', + false, + ); + } + } + return { control, catalog: { ...catalog, control } }; +} + const OPERATION_ERROR_CODES = new Set([ 'invalid_request', 'host_unavailable', 'host_capability_unavailable', + 'trust_required', 'policy_incompatible', 'policy_limited', 'stale_revision', 'conflict', 'not_found', 'unavailable', + 'runtime_unavailable', + 'unsupported', + 'incompatible_version', + 'dependency_failed', + 'timeout', + 'cancelled', + 'overloaded', + 'process_lost', + 'invalid_response', + 'temporarily_unavailable', 'internal', ]); +function normalizeOperationReference(value: unknown): string | undefined { + if (typeof value !== 'string' + || value.length === 0 + || value.length > 160 + || value.trim() !== value + || /[\u0000-\u001f\u007f-\u009f]/u.test(value)) { + return undefined; + } + return value; +} + +function normalizeOperationDetail(value: string): string { + const bounded = Array.from(value) + .slice(0, 4096) + .join('') + .replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); + return bounded.trim() ? bounded : 'External source operation failed'; +} + function parseOperationError(value: unknown, visited = new Set()): ExternalSourceApiError | null { if (value === null || value === undefined || visited.has(value)) return null; visited.add(value); @@ -577,9 +915,12 @@ function parseOperationError(value: unknown, visited = new Set()): Exte ) { return new ExternalSourceApiError( record.code as ExternalSourceOperationErrorCode, - record.detail, + normalizeOperationDetail(record.detail), record.retryable === true, - typeof record.correlationId === 'string' ? record.correlationId : undefined, + normalizeOperationReference(record.correlationId), + normalizeOperationReference(record.causationId), + isOneOf(record.stage, OPERATION_STAGES) ? record.stage : undefined, + normalizeRecoveryActions(record.recoveryActions, false), ); } for (const candidate of [ @@ -600,7 +941,37 @@ async function invokeExternal(command: string, args: Record) try { return await api.invoke(command, args); } catch (error) { - throw parseOperationError(error) ?? new ExternalSourceApiError( + const parsed = parseOperationError(error); + let raw = typeof error === 'string' + ? error + : error instanceof Error + ? error.message + : ''; + if (!raw) { + try { + raw = JSON.stringify(error) ?? ''; + } catch { + raw = ''; + } + } + const normalized = raw.toLowerCase(); + const legacyPeerMissingCommand = raw.includes(command) + && normalized.includes('not supported on cli peer host'); + const legacyServerMissingCommand = parsed?.code === 'host_capability_unavailable' + && parsed.detail === 'Unknown Server Host operation'; + if (legacyPeerMissingCommand + || legacyServerMissingCommand + || (raw.includes(command) + && (normalized.includes('unknown command') + || normalized.includes('not found') + || normalized.includes('not registered')))) { + throw new ExternalSourceApiError( + 'incompatible_version', + 'The connected Host does not support this external source command', + false, + ); + } + throw parsed ?? new ExternalSourceApiError( 'internal', 'External source operation failed', false, @@ -612,16 +983,92 @@ async function invokeSnapshot( command: string, args: Record, ): Promise { - return normalizeSnapshot(await invokeExternal(command, args)); + await invokeExternal(command, args); + const request = args.request && typeof args.request === 'object' + ? args.request as Record + : {}; + return (await invokeCompatibleSurfaceSnapshot({ + request: { + workspacePath: request.workspacePath, + forceRefresh: false, + }, + })).catalog; +} + +async function invokeSurfaceSnapshot( + command: string, + args: Record, +): Promise { + return normalizeSurfaceSnapshot(await invokeExternal(command, args)); +} + +function legacySurfaceSnapshot(catalog: ExternalSourceCatalogSnapshot): ExternalSourceSurfaceSnapshot { + const hostCapabilities = { + ...catalog.hostCapabilities, + canSetSafeMode: false, + canRevealSourceLocation: false, + }; + const control: ExternalSourceControlSnapshot = { + schemaVersion: 1, + executionDomainId: catalog.sources[0]?.record.executionDomainId ?? 'legacy-host', + refreshGeneration: catalog.generation, + preferenceRevision: catalog.preferenceRevision ?? 0, + safeMode: false, + hostCapabilities, + sources: [], + capabilities: [], + diagnostics: catalog.diagnostics ?? [], + recoveryActions: [{ type: 'reconnect_host' }], + }; + return { + control, + catalog: { ...catalog, hostCapabilities, control }, + }; +} + +async function invokeCompatibleSurfaceSnapshot( + args: Record, +): Promise { + try { + return await invokeSurfaceSnapshot('get_external_source_control_snapshot', args); + } catch (error) { + if (!(error instanceof ExternalSourceApiError) || error.code !== 'incompatible_version') { + throw error; + } + const catalog = normalizeSnapshot(await invokeExternal( + 'get_external_source_snapshot', + args, + )); + return legacySurfaceSnapshot(catalog); + } } function normalizeOptionalWorkspacePath(workspacePath: string | undefined): string | undefined { return workspacePath?.trim() ? workspacePath : undefined; } +let operationSequence = 0; + +function nextOperationId(action: ExternalSourceControlAction['type']): string { + operationSequence += 1; + return `web-${action}-${Date.now().toString(36)}-${operationSequence.toString(36)}`; +} + +function controlRequest( + action: ExternalSourceControlAction, + expectedPreferenceRevision?: number, +): ExternalSourceControlRequest { + return { + schemaVersion: 1, + operationId: nextOperationId(action.type), + ...(expectedPreferenceRevision === undefined ? {} : { expectedPreferenceRevision }), + action, + }; +} + export const externalSourcesAPI = { - getSnapshot(workspacePath?: string, forceRefresh = false) { - return invokeSnapshot('get_external_source_snapshot', { + async getControlSnapshot(workspacePath?: string, forceRefresh = false) { + return invokeCompatibleSurfaceSnapshot({ request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), forceRefresh }, }); }, @@ -635,20 +1082,60 @@ export const externalSourcesAPI = { }); }, - setSourceEnabled( + async getSnapshot(workspacePath?: string, forceRefresh = false) { + return (await invokeCompatibleSurfaceSnapshot({ + request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), forceRefresh }, + })).catalog; + }, + + async setSourceEnabled( workspacePath: string | undefined, sourceKey: string, enabled: boolean, expectedPreferenceRevision: number, ) { - return invokeSnapshot('set_external_source_enabled_command', { + const normalizedWorkspacePath = normalizeOptionalWorkspacePath(workspacePath); + try { + const surface = await invokeSurfaceSnapshot('apply_external_source_control_action_command', { + request: { + workspacePath: normalizedWorkspacePath, + control: controlRequest( + { type: 'set_source_enabled', sourceKey, enabled }, + expectedPreferenceRevision, + ), + }, + }); + return surface.catalog; + } catch (error) { + if (!(error instanceof ExternalSourceApiError) || error.code !== 'incompatible_version') { + throw error; + } + return invokeSnapshot('set_external_source_enabled_command', { + request: { + workspacePath: normalizedWorkspacePath, + sourceKey, + enabled, + expectedPreferenceRevision, + }, + }); + } + }, + + async setSafeMode( + workspacePath: string | undefined, + enabled: boolean, + expectedPreferenceRevision: number, + ) { + const surface = await invokeSurfaceSnapshot('apply_external_source_control_action_command', { request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), - sourceKey, - enabled, - expectedPreferenceRevision, + control: controlRequest( + { type: 'set_safe_mode', enabled }, + expectedPreferenceRevision, + ), }, }); + return surface.catalog; }, setConflictChoice( diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index 36277a05ec..e52988c3a0 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -270,6 +270,14 @@ cursor: pointer; } + &__recovery-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; + } + &__diagnostics { margin: 8px 0 0; padding-left: 20px; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index a739f5bf71..c2349258af 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -7,6 +7,7 @@ import ExternalSourcesConfig from './ExternalSourcesConfig'; const getSnapshotMock = vi.hoisted(() => vi.fn()); const setSourceEnabledMock = vi.hoisted(() => vi.fn()); +const setSafeModeMock = vi.hoisted(() => vi.fn()); const setConflictChoiceMock = vi.hoisted(() => vi.fn()); const setToolTargetDecisionMock = vi.hoisted(() => vi.fn()); const setToolConflictChoiceMock = vi.hoisted(() => vi.fn()); @@ -67,6 +68,7 @@ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ externalSourcesAPI: { getSnapshot: getSnapshotMock, setSourceEnabled: setSourceEnabledMock, + setSafeMode: setSafeModeMock, setConflictChoice: setConflictChoiceMock, setToolTargetDecision: setToolTargetDecisionMock, setToolConflictChoice: setToolConflictChoiceMock, @@ -86,6 +88,8 @@ const snapshot = { canManageSources: true, canApproveRuntime: true, canExecuteExternalAssets: true, + canSetSafeMode: true, + canRevealSourceLocation: true, }, generation: 1, discoveryPending: false, @@ -238,6 +242,7 @@ describe('ExternalSourcesConfig', () => { peerState.deviceId = ''; getSnapshotMock.mockResolvedValue(snapshot); setSourceEnabledMock.mockResolvedValue(snapshot); + setSafeModeMock.mockResolvedValue(snapshot); setConflictChoiceMock.mockResolvedValue({ ...snapshot, commandConflicts: [{ @@ -311,6 +316,125 @@ describe('ExternalSourcesConfig', () => { }); }); + it('uses the shared control projection to enter Safe Mode without hiding commands', async () => { + const controlledSnapshot = { + ...snapshot, + preferenceRevision: 4, + commands: [discoveredCommand], + control: { + schemaVersion: 1, + executionDomainId: 'local-user', + refreshGeneration: 1, + preferenceRevision: 4, + safeMode: false, + hostCapabilities: snapshot.hostCapabilities, + sources: [], + capabilities: [], + diagnostics: [], + recoveryActions: [], + }, + }; + getSnapshotMock.mockResolvedValue(controlledSnapshot); + setSafeModeMock.mockResolvedValue({ + ...controlledSnapshot, + control: { ...controlledSnapshot.control, safeMode: true }, + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('safeMode.title'); + expect(container.textContent).toContain('sources.commandCount:{"count":1}'); + const toggle = container.querySelector( + 'input[aria-label="safeMode.toggleLabel"]', + ) as HTMLInputElement; + await act(async () => toggle.click()); + + expect(setSafeModeMock).toHaveBeenCalledWith('D:/workspace/project', true, 4); + expect(container.textContent).toContain('safeMode.activeNotice'); + }); + + it('keeps Safe Mode available when the persisted integration policy is incompatible', async () => { + const controlledSnapshot = { + ...snapshot, + preferenceRevision: 4, + integrationPolicy: { + ...integrationPolicy, + status: 'incompatible_schema' as const, + schemaMajor: 99, + }, + control: { + schemaVersion: 1 as const, + executionDomainId: 'local-user', + refreshGeneration: 1, + preferenceRevision: 4, + safeMode: false, + hostCapabilities: snapshot.hostCapabilities, + sources: [], + capabilities: [], + diagnostics: [], + recoveryActions: [], + }, + }; + getSnapshotMock.mockResolvedValue(controlledSnapshot); + setSafeModeMock.mockResolvedValue({ + ...controlledSnapshot, + control: { ...controlledSnapshot.control, safeMode: true }, + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const toggle = container.querySelector( + 'input[aria-label="safeMode.toggleLabel"]', + ) as HTMLInputElement; + expect(toggle.disabled).toBe(false); + await act(async () => toggle.click()); + + expect(setSafeModeMock).toHaveBeenCalledWith('D:/workspace/project', true, 4); + }); + + it('explains the upgrade boundary when a legacy Host cannot provide Safe Mode', async () => { + getSnapshotMock.mockResolvedValue({ + ...snapshot, + control: { + schemaVersion: 1 as const, + executionDomainId: 'remote-user', + refreshGeneration: snapshot.generation, + preferenceRevision: snapshot.preferenceRevision, + safeMode: false, + hostCapabilities: { + ...snapshot.hostCapabilities, + canSetSafeMode: false, + }, + sources: [], + capabilities: [], + diagnostics: [], + recoveryActions: [{ type: 'reconnect_host' as const }], + }, + hostCapabilities: { + ...snapshot.hostCapabilities, + canSetSafeMode: false, + }, + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('legacyHostNotice'); + expect(container.textContent).toContain('recoveryActions.reconnect_host'); + const toggle = container.querySelector( + 'input[aria-label="safeMode.toggleLabel"]', + ) as HTMLInputElement; + expect(toggle.disabled).toBe(true); + }); + it('shows the effective safety ceiling when stored policy requests broader access', async () => { getSnapshotMock.mockResolvedValue({ ...snapshot, @@ -827,7 +951,7 @@ describe('ExternalSourcesConfig', () => { }, approvalKey: 'tool-approval-v1', decisionKey: 'tool-decision-v1', - activation: { state: 'disabled' }, + activation: { state: 'declined' }, }], toolConflicts: [{ conflictKey: 'tool-conflict-v1', @@ -1474,7 +1598,7 @@ describe('ExternalSourcesConfig', () => { }, approvalKey: 'approval-1', decisionKey: 'decision-1', - activation: { state: 'disabled' }, + activation: { state: 'declined' }, }], toolApprovalRequests: [], }; @@ -2058,7 +2182,14 @@ describe('ExternalSourcesConfig', () => { getSnapshotMock.mockResolvedValue(snapshot); setSourceEnabledMock.mockRejectedValueOnce(Object.assign( new Error('database connection string should stay private'), - { code: 'internal', retryable: true, correlationId: 'external-source-ref-7' }, + { + code: 'internal', + retryable: true, + correlationId: 'external-source-ref-7', + causationId: 'refresh-generation-6', + stage: 'activate_runtime', + recoveryActions: [{ type: 'retry' }, { type: 'reconnect_host' }], + }, )); await act(async () => { @@ -2072,7 +2203,17 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('operationErrors.internal'); expect(container.textContent).toContain('external-source-ref-7'); + expect(container.textContent).toContain('operationErrors.stage:{"stage":"activate_runtime"}'); + expect(container.textContent).toContain('operationErrors.causationId:{"id":"refresh-generation-6"}'); + expect(container.textContent).toContain('recoveryActions.retry'); + expect(container.textContent).toContain('recoveryActions.reconnect_host'); expect(container.textContent).not.toContain('database connection string'); + + const retry = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'recoveryActions.retry', + ); + await act(async () => retry?.click()); + expect(setSourceEnabledMock).toHaveBeenCalledTimes(2); }); it('shows MCP empty state when no MCP servers found', async () => { @@ -2256,6 +2397,124 @@ describe('ExternalSourcesConfig', () => { ); }); + it('does not expose source-location actions when the Host does not support them', async () => { + const readOnlyHostSnapshot = { + ...snapshot, + hostCapabilities: { + ...snapshot.hostCapabilities, + canRevealSourceLocation: false, + }, + integrationPolicy, + commandConflicts: [], + sources: [{ + ...snapshot.sources[0], + stableKey: 'opencode-source', + presentationGroupId: 'opencode-source', + record: { + ...snapshot.sources[0].record, + ecosystemId: 'opencode', + }, + lifecycle: 'suppressed', + }, { + ...snapshot.sources[0], + stableKey: 'opencode-source-secondary', + presentationGroupId: 'opencode-source-secondary', + record: { + ...snapshot.sources[0].record, + key: { providerId: 'opencode.commands', sourceId: 'user' }, + ecosystemId: 'opencode', + scope: 'user_global', + contentVersion: 'v2', + }, + lifecycle: 'suppressed', + }], + }; + getSnapshotMock.mockResolvedValue(readOnlyHostSnapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const expandButton = container.querySelector( + 'button[aria-controls="external-capabilities-opencode"]', + ); + await act(async () => { + (expandButton as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect(container.querySelector( + '.bitfun-external-sources-config__opencode-locations a.bitfun-external-sources-config__path-link', + )).toBeNull(); + expect(container.querySelector( + '.bitfun-external-sources-config__opencode-locations .bitfun-external-sources-config__path-link--disabled', + )?.getAttribute('aria-label')).toBe('common.openInExplorerUnavailable'); + expect(revealSourceLocationMock).not.toHaveBeenCalled(); + }); + + it('shows typed recovery feedback when revealing a source location fails', async () => { + const opencodeSnapshot = { + ...snapshot, + integrationPolicy, + commandConflicts: [], + sources: [{ + ...snapshot.sources[0], + stableKey: 'opencode-source', + presentationGroupId: 'opencode-source', + record: { + ...snapshot.sources[0].record, + ecosystemId: 'opencode', + }, + lifecycle: 'suppressed', + }, { + ...snapshot.sources[0], + stableKey: 'opencode-source-secondary', + presentationGroupId: 'opencode-source-secondary', + record: { + ...snapshot.sources[0].record, + key: { providerId: 'opencode.commands', sourceId: 'user' }, + ecosystemId: 'opencode', + scope: 'user_global', + contentVersion: 'v2', + }, + lifecycle: 'suppressed', + }], + }; + getSnapshotMock.mockResolvedValue(opencodeSnapshot); + revealSourceLocationMock.mockRejectedValueOnce({ + code: 'not_found', + message: 'The source is no longer available', + retryable: false, + recoveryActions: [{ type: 'refresh' }], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const expandButton = container.querySelector( + 'button[aria-controls="external-capabilities-opencode"]', + ); + await act(async () => { + (expandButton as HTMLButtonElement).click(); + await Promise.resolve(); + }); + const pathLink = container.querySelector( + '.bitfun-external-sources-config__opencode-locations a.bitfun-external-sources-config__path-link', + ); + await act(async () => { + (pathLink as HTMLAnchorElement).click(); + await Promise.resolve(); + }); + + expect(container.querySelector('[role="alert"]')?.textContent) + .toContain('operationErrors.rejected'); + expect(container.querySelector('[role="alert"]')?.textContent) + .toContain('recoveryActions.refresh'); + }); + it('labels all OpenCode scopes with supported translations and includes tools in the summary', async () => { const opencodeSnapshot = { ...snapshot, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 03621cac8c..bca2de90f2 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -32,6 +32,7 @@ import { type ExternalIntegrationMode, type ExternalIntegrationPolicyMutation, type ExternalSourceCatalogSnapshot, + type ExternalSourceRecoveryAction, type ExternalSubagentSummary, type ExternalToolCatalogEntry, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; @@ -136,6 +137,9 @@ type ExternalSourcesError = { detail: string; retryable: boolean; correlationId?: string; + causationId?: string; + stage?: string; + recoveryActions: ExternalSourceRecoveryAction[]; }; type AgentChangeNotice = { @@ -146,7 +150,7 @@ type AgentChangeNotice = { function externalOperationErrorFacts(error: unknown): Pick< ExternalSourcesError, -'code' | 'detail' | 'retryable' | 'correlationId' +'code' | 'detail' | 'retryable' | 'correlationId' | 'causationId' | 'stage' | 'recoveryActions' > { if (error && typeof error === 'object') { const candidate = error as { @@ -154,6 +158,9 @@ ExternalSourcesError, message?: unknown; retryable?: unknown; correlationId?: unknown; + causationId?: unknown; + stage?: unknown; + recoveryActions?: unknown; }; const code = typeof candidate.code === 'string' ? candidate.code : undefined; return { @@ -165,11 +172,17 @@ ExternalSourcesError, correlationId: typeof candidate.correlationId === 'string' ? candidate.correlationId : undefined, + causationId: typeof candidate.causationId === 'string' ? candidate.causationId : undefined, + stage: typeof candidate.stage === 'string' ? candidate.stage : undefined, + recoveryActions: Array.isArray(candidate.recoveryActions) + ? candidate.recoveryActions as ExternalSourceRecoveryAction[] + : [], }; } return { detail: 'External source operation failed', retryable: false, + recoveryActions: [], }; } @@ -256,6 +269,7 @@ const ExternalSourcesConfig: React.FC = () => { const pendingMutations = useRef(new Map()); const latestMutationByScope = useRef(new Map()); const activeMutation = useRef<{ scope: string; sequence: number } | null>(null); + const lastFailedMutationRef = useRef<(() => Promise) | null>(null); const foregroundSequence = useRef(null); const requestScope = externalSourceRequestScopeKey({ peerDeviceId, @@ -420,6 +434,7 @@ const ExternalSourcesConfig: React.FC = () => { setReviewingMcpKey(null); setReviewingMcpConflictKey(null); setResetPolicyConfirmation(null); + lastFailedMutationRef.current = null; setLoading(true); setPolicyScope(workspacePath ? 'workspace' : 'user'); void loadSnapshot(false, false); @@ -509,14 +524,18 @@ const ExternalSourcesConfig: React.FC = () => { canManageSources: false, canApproveRuntime: false, canExecuteExternalAssets: false, + canSetSafeMode: false, + canRevealSourceLocation: false, }; + const control = snapshot?.control; const policyStatus = snapshot?.integrationPolicy?.status; const policyCompatible = policyStatus === 'compatible'; const policyIncompatible = policyStatus === 'incompatible_schema'; const policyUnknown = !policyCompatible && !policyIncompatible; const hostReadOnly = !hostCapabilities.canMutatePolicy && !hostCapabilities.canManageSources - && !hostCapabilities.canApproveRuntime; + && !hostCapabilities.canApproveRuntime + && !hostCapabilities.canSetSafeMode; const remoteWorkspace = workspace?.workspaceKind === WorkspaceKind.Remote; const readOnlyHintKey = remoteWorkspace ? 'policy.remoteReadOnlyHint' @@ -528,19 +547,26 @@ const ExternalSourcesConfig: React.FC = () => { _focusResult = false, partition: 'all' | 'subagents' = 'all', successMessage?: string, - requiredCapability: 'canMutatePolicy' | 'canManageSources' | 'canApproveRuntime' = 'canManageSources', - allowIncompatiblePolicy = false, + requiredCapability: + | 'canMutatePolicy' + | 'canManageSources' + | 'canApproveRuntime' + | 'canSetSafeMode' = 'canManageSources', + policyGate: 'compatible' | 'compatible_or_incompatible' | 'none' = 'compatible', ): Promise => { const current = snapshotRef.current; const currentCapabilities = current?.hostCapabilities ?? { canMutatePolicy: false, canManageSources: false, canApproveRuntime: false, + canSetSafeMode: false, }; const currentStatus = current?.integrationPolicy.status; if (!current || currentCapabilities[requiredCapability] !== true - || (currentStatus !== 'compatible' - && !(allowIncompatiblePolicy && currentStatus === 'incompatible_schema'))) { + || (policyGate === 'compatible' && currentStatus !== 'compatible') + || (policyGate === 'compatible_or_incompatible' + && currentStatus !== 'compatible' + && currentStatus !== 'incompatible_schema')) { setOperationStatus(t(readOnlyHintKey)); return false; } @@ -560,6 +586,7 @@ const ExternalSourcesConfig: React.FC = () => { const next = await request(); const accepted = acceptMutationSnapshot(next, scope, sequence, partition); if (accepted) { + lastFailedMutationRef.current = null; setOperationStatus(successMessage ?? t('actions.updated')); } return accepted; @@ -567,7 +594,21 @@ const ExternalSourcesConfig: React.FC = () => { if (requestScopeRef.current === scope && latestMutationByScope.current.get(scope) === sequence) { acceptedSequence.current = sequence; - setError({ kind: 'mutation', ...externalOperationErrorFacts(updateError) }); + const facts = externalOperationErrorFacts(updateError); + lastFailedMutationRef.current = facts.recoveryActions.some( + (action) => action.type === 'retry', + ) + ? () => runMutation( + mutationKey, + request, + _focusResult, + partition, + successMessage, + requiredCapability, + policyGate, + ) + : null; + setError({ kind: 'mutation', ...facts }); } return false; } finally { @@ -599,6 +640,24 @@ const ExternalSourcesConfig: React.FC = () => { ); }, [runMutation, workspacePath]); + const setSafeMode = useCallback(async (enabled: boolean) => { + const currentSnapshot = snapshotRef.current; + if (!currentSnapshot?.control) return; + await runMutation( + 'external-safe-mode', + () => externalSourcesAPI.setSafeMode( + workspacePath, + enabled, + currentSnapshot.control?.preferenceRevision ?? 0, + ), + false, + 'all', + t(enabled ? 'safeMode.entered' : 'safeMode.exited'), + 'canSetSafeMode', + 'none', + ); + }, [runMutation, t, workspacePath]); + const chooseConflict = useCallback(async (conflictKey: string, candidateId: string) => { if (!snapshot) return; await runMutation( @@ -863,7 +922,9 @@ const ExternalSourcesConfig: React.FC = () => { 'all', t('policy.updated'), 'canMutatePolicy', - change.operation === 'reset_incompatible_policy', + change.operation === 'reset_incompatible_policy' + ? 'compatible_or_incompatible' + : 'compatible', ); }, [policyScope, runMutation, snapshot, t, workspacePath]); @@ -895,7 +956,7 @@ const ExternalSourcesConfig: React.FC = () => { 'all', t('policy.recoveryResetComplete'), 'canMutatePolicy', - true, + 'compatible_or_incompatible', ); }, [requestScope, runMutation, t]); @@ -919,19 +980,48 @@ const ExternalSourcesConfig: React.FC = () => { target.focus(); }, []); + const revealSourceLocation = useCallback(async (sourceKey: string): Promise => { + const scope = requestScope; + if (snapshotRef.current?.hostCapabilities.canRevealSourceLocation !== true) { + setOperationStatus(t('common.openInExplorerUnavailable')); + return false; + } + setOperationStatus(null); + setError(null); + try { + await externalSourcesAPI.revealSourceLocation(workspacePath, sourceKey); + if (requestScopeRef.current === scope) lastFailedMutationRef.current = null; + return true; + } catch (revealError) { + const facts = externalOperationErrorFacts(revealError); + logger.warn('Could not reveal external source location', { + sourceKey, + code: facts.code ?? 'internal', + }); + if (requestScopeRef.current === scope) { + lastFailedMutationRef.current = facts.recoveryActions.some( + (action) => action.type === 'retry', + ) + ? () => revealSourceLocation(sourceKey) + : null; + setError({ kind: 'mutation', ...facts }); + } + return false; + } + }, [requestScope, t, workspacePath]); + const renderPathLink = useCallback((location: string, sourceKey?: string) => { const display = abbreviatedLocation(location); - if (isRemote || !sourceKey) { - if (!isRemote) { - return ( - - {display} - - ); - } + if (isRemote || !sourceKey || !hostCapabilities.canRevealSourceLocation) { + const unavailableMessage = isRemote + ? t('common.openInExplorerRemote') + : t('common.openInExplorerUnavailable'); return ( - - + + {display} @@ -945,15 +1035,13 @@ const ExternalSourcesConfig: React.FC = () => { translate="no" onClick={(event) => { event.preventDefault(); - void externalSourcesAPI.revealSourceLocation(workspacePath, sourceKey).catch((error) => { - logger.warn('Could not reveal external source location', { sourceKey, error }); - }); + void revealSourceLocation(sourceKey); }} > {display} ); - }, [isRemote, t, workspacePath]); + }, [hostCapabilities.canRevealSourceLocation, isRemote, revealSourceLocation, t]); const renderSourceMembers = useCallback((group: ExternalSourcePresentationGroup) => (
{ {error.correlationId ? (
{t('operationErrors.referenceId', { id: error.correlationId })}
) : null} + {error.recoveryActions.length > 0 ? ( +
+ {error.recoveryActions.map((action) => { + if (action.type === 'refresh') { + return ( + + ); + } + if (action.type === 'retry') { + return ( + + ); + } + if (action.type === 'exit_safe_mode' && control?.safeMode) { + return ( + + ); + } + if (action.type === 'review' || action.type === 'resolve_conflict') { + return ( + + ); + } + return ( + {t(`recoveryActions.${action.type}`)} + ); + })} +
+ ) : null}
{t('common.technicalDetails')}
{error.detail}
+ {error.stage ? ( +
{t('operationErrors.stage', { stage: error.stage })}
+ ) : null} + {error.causationId ? ( +
{t('operationErrors.causationId', { id: error.causationId })}
+ ) : null}
) : null} @@ -1065,6 +1222,40 @@ const ExternalSourcesConfig: React.FC = () => { {t(readOnlyHintKey)} ) : null} + {control?.recoveryActions.some((action) => action.type === 'reconnect_host') ? ( +
+
{t('legacyHostNotice')}
+
{t('recoveryActions.reconnect_host')}
+
+ ) : null} + {snapshot && control ? ( + void setSafeMode(event.currentTarget.checked)} + /> + )} + > + {control.safeMode ? ( +
+ {t('safeMode.activeNotice')} +
+ ) : null} +
+ ) : null} {snapshot && policy ? ( { === tool.definition.id.target.localId )); const firstTargetExport = targetTools[0] === tool; - const enableable = ['approval_required', 'disabled'].includes( + const enableable = ['approval_required', 'declined'].includes( tool.activation.state, ); const disableable = firstTargetExport && targetTools.some((candidate) => ( diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 6425241dec..ac15f64ca1 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -1,6 +1,7 @@ { "title": "External AI applications", "subtitle": "Use commands, tools, agents, and MCP from other AI applications.", + "legacyHostNotice": "This execution Host uses the legacy external-source protocol. Safe Mode requires a Host upgrade.", "loading": "Checking external sources…", "checkingNonBlocking": "Checking for updates…", "common": { @@ -14,7 +15,8 @@ "notSelected": "Not used", "availableChoice": "Available choice", "openInExplorer": "Open in file explorer", - "openInExplorerRemote": "Remote workspace paths cannot be opened in the local file manager" + "openInExplorerRemote": "Remote workspace paths cannot be opened in the local file manager", + "openInExplorerUnavailable": "This Host cannot open source locations" }, "actions": { "refresh": "Refresh", @@ -24,6 +26,24 @@ "agentUpdated": "Saved changes to {{name}} and refreshed its status.", "mcpUpdated": "Saved the MCP server choice and refreshed its status." }, + "recoveryActions": { + "refresh": "Refresh status", + "retry": "Retry", + "review": "Review items", + "resolve_conflict": "Resolve conflict", + "install_runtime": "Install or repair the required runtime, then refresh.", + "reconnect_host": "Reconnect or upgrade the execution Host, then retry.", + "exit_safe_mode": "Exit Safe Mode" + }, + "safeMode": { + "title": "External Safe Mode", + "description": "Block new external tool, agent, and MCP calls while keeping discovered commands and diagnostics visible. Calls already in progress are not cancelled.", + "activeDescription": "New executable external calls are blocked. Calls already in progress may finish; commands and diagnostics remain available for recovery.", + "activeNotice": "Safe Mode applies only to this Host process and execution domain. Restarting or switching the execution Host turns it off.", + "toggleLabel": "Use External Safe Mode", + "entered": "External Safe Mode is active.", + "exited": "External Safe Mode is off and eligible integrations were reconciled." + }, "unavailable": { "hostTitle": "Unavailable here", "hostDescription": "View or manage external settings in Desktop.", @@ -105,7 +125,9 @@ "policyIncompatible": "External settings were written by a newer BitFun version. Back up and reset them before making changes.", "unavailableRetry": "Temporarily unavailable. Try again later.", "internal": "The action failed. Refresh to retry.", - "referenceId": "Reference: {{id}}" + "referenceId": "Reference: {{id}}", + "stage": "Stage: {{stage}}", + "causationId": "Related operation: {{id}}" }, "diagnostics": { "summary": "{{count}} items need attention. Review the details to see what is affected.", @@ -315,6 +337,7 @@ }, "toolState": { "approval_required": "Confirmation required", + "declined": "Kept disabled", "disabled": "Disabled", "active": "Enabled", "conflict": "Choice required", @@ -324,7 +347,8 @@ }, "toolReason": { "approval_required": "Review this tool file's access before enabling it.", - "disabled": "You chose to disable this tool source.", + "declined": "You chose to keep these tools disabled.", + "disabled": "The source or its tool capability is disabled by policy.", "active": "The tool code is loaded and ready to use.", "conflict": "Another tool has the same name.", "unsupported": "This tool file contains code or operations that BitFun does not support.", @@ -333,7 +357,8 @@ }, "toolNextStep": { "approval_required": "Review the source and access above, then enable or keep these tools disabled.", - "disabled": "Review the source and access above before enabling these tools.", + "declined": "Review the source and access above before enabling these tools.", + "disabled": "Enable this source or its tool capability before using these tools.", "active": "No action needed. Disable these tools to stop using this source's tools.", "conflict": "Choose which tool to use below, or leave them disabled.", "unsupported": "Change the source to a single JavaScript file supported by BitFun, then refresh.", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index d2e531f89d..7e33c344ff 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -1,6 +1,7 @@ { "title": "外部 AI 应用", "subtitle": "使用其他 AI 应用的命令、工具、Agent 和 MCP。", + "legacyHostNotice": "当前执行 Host 使用旧版外部来源协议;升级 Host 后才能使用安全模式。", "loading": "正在检查外部来源…", "checkingNonBlocking": "正在检查更新…", "common": { @@ -14,7 +15,8 @@ "notSelected": "未使用", "availableChoice": "可选", "openInExplorer": "在文件管理器中打开", - "openInExplorerRemote": "远程工作区路径无法在本地文件管理器中打开" + "openInExplorerRemote": "远程工作区路径无法在本地文件管理器中打开", + "openInExplorerUnavailable": "当前 Host 无法打开来源位置" }, "actions": { "refresh": "刷新", @@ -24,6 +26,24 @@ "agentUpdated": "已保存“{{name}}”的更改并刷新状态。", "mcpUpdated": "已保存 MCP 服务器选择并刷新状态。" }, + "recoveryActions": { + "refresh": "刷新状态", + "retry": "重试", + "review": "检查待处理项", + "resolve_conflict": "解决冲突", + "install_runtime": "安装或修复所需运行时,然后刷新。", + "reconnect_host": "重新连接或升级执行 Host,然后重试。", + "exit_safe_mode": "退出安全模式" + }, + "safeMode": { + "title": "外部来源安全模式", + "description": "阻止新的外部工具、Agent 和 MCP 调用,同时保留已发现的命令与诊断信息;已在执行的调用不会被取消。", + "activeDescription": "新的外部可执行调用已被阻止;已在执行的调用仍可能完成,命令和诊断信息继续可用于恢复。", + "activeNotice": "安全模式仅对当前 Host 进程和执行域有效;重启或切换执行 Host 后会自动关闭。", + "toggleLabel": "使用外部来源安全模式", + "entered": "外部来源安全模式已启用。", + "exited": "外部来源安全模式已关闭,符合条件的集成已重新协调。" + }, "unavailable": { "hostTitle": "此处暂不可用", "hostDescription": "请在桌面端查看或管理外部配置。", @@ -105,7 +125,9 @@ "policyIncompatible": "外部设置由较新的 BitFun 版本写入。请先备份并重置,再进行更改。", "unavailableRetry": "暂时不可用,请稍后重试。", "internal": "操作失败,请刷新重试。", - "referenceId": "参考编号:{{id}}" + "referenceId": "参考编号:{{id}}", + "stage": "处理阶段:{{stage}}", + "causationId": "关联操作:{{id}}" }, "diagnostics": { "summary": "有 {{count}} 项内容需要注意,请查看详情了解影响范围。", @@ -315,6 +337,7 @@ }, "toolState": { "approval_required": "待确认", + "declined": "已保持停用", "disabled": "已停用", "active": "已启用", "conflict": "需要选择", @@ -324,7 +347,8 @@ }, "toolReason": { "approval_required": "启用前,需要确认这个工具文件的访问范围。", - "disabled": "你已选择停用这个工具来源。", + "declined": "你已选择保持这些工具停用。", + "disabled": "来源或其工具能力已被策略停用。", "active": "工具代码已加载,可以使用。", "conflict": "另一个工具使用了相同名称。", "unsupported": "这个工具文件包含 BitFun 当前不支持的语法或操作。", @@ -333,7 +357,8 @@ }, "toolNextStep": { "approval_required": "确认上方来源和访问范围后,启用或继续停用这些工具。", - "disabled": "确认上方来源和访问范围后,再启用这些工具。", + "declined": "确认上方来源和访问范围后,再启用这些工具。", + "disabled": "请先启用此来源或其工具能力。", "active": "无需操作。如需停止使用该来源的全部工具,请停用这些工具。", "conflict": "请在下方选择要使用的工具,或继续保持停用。", "unsupported": "请将来源改为 BitFun 当前支持的单文件 JavaScript 格式,然后刷新。", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 18d0277d8f..e76d705472 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -1,6 +1,7 @@ { "title": "外部 AI 應用", "subtitle": "使用其他 AI 應用的命令、工具、Agent 與 MCP。", + "legacyHostNotice": "目前執行 Host 使用舊版外部來源協定;升級 Host 後才能使用安全模式。", "loading": "正在檢查外部來源…", "checkingNonBlocking": "正在檢查更新…", "common": { @@ -14,7 +15,8 @@ "notSelected": "未使用", "availableChoice": "可選", "openInExplorer": "在檔案管理員中開啟", - "openInExplorerRemote": "遠端工作區路徑無法在本機檔案管理員中開啟" + "openInExplorerRemote": "遠端工作區路徑無法在本機檔案管理員中開啟", + "openInExplorerUnavailable": "目前 Host 無法開啟來源位置" }, "actions": { "refresh": "重新整理", @@ -24,6 +26,24 @@ "agentUpdated": "已儲存「{{name}}」的變更並重新整理狀態。", "mcpUpdated": "已儲存 MCP 伺服器選擇並重新整理狀態。" }, + "recoveryActions": { + "refresh": "重新整理狀態", + "retry": "重試", + "review": "檢查待處理項目", + "resolve_conflict": "解決衝突", + "install_runtime": "安裝或修復所需執行階段,然後重新整理。", + "reconnect_host": "重新連線或升級執行 Host,然後重試。", + "exit_safe_mode": "退出安全模式" + }, + "safeMode": { + "title": "外部來源安全模式", + "description": "阻止新的外部工具、Agent 與 MCP 呼叫,同時保留已探索的命令與診斷資訊;已在執行的呼叫不會被取消。", + "activeDescription": "新的外部可執行呼叫已被阻止;已在執行的呼叫仍可能完成,命令與診斷資訊可繼續用於復原。", + "activeNotice": "安全模式僅對目前的 Host 程序與執行域有效;重新啟動或切換執行 Host 後會自動關閉。", + "toggleLabel": "使用外部來源安全模式", + "entered": "外部來源安全模式已啟用。", + "exited": "外部來源安全模式已關閉,符合條件的整合已重新協調。" + }, "unavailable": { "hostTitle": "此處暫時無法使用", "hostDescription": "請在桌面端檢視或管理外部設定。", @@ -105,7 +125,9 @@ "policyIncompatible": "外部設定由較新的 BitFun 版本寫入。請先備份並重設,再進行變更。", "unavailableRetry": "暫時無法使用,請稍後重試。", "internal": "操作失敗,請重新整理後重試。", - "referenceId": "參考編號:{{id}}" + "referenceId": "參考編號:{{id}}", + "stage": "處理階段:{{stage}}", + "causationId": "關聯操作:{{id}}" }, "diagnostics": { "summary": "有 {{count}} 項內容需要注意,請查看詳細資料以瞭解影響範圍。", @@ -315,6 +337,7 @@ }, "toolState": { "approval_required": "待確認", + "declined": "已保持停用", "disabled": "已停用", "active": "已啟用", "conflict": "需要選擇", @@ -324,7 +347,8 @@ }, "toolReason": { "approval_required": "啟用前,需要確認此工具檔案的存取範圍。", - "disabled": "你已選擇停用此工具來源。", + "declined": "你已選擇保持這些工具停用。", + "disabled": "來源或其工具能力已被策略停用。", "active": "工具程式碼已載入,可以使用。", "conflict": "另一個工具使用了相同名稱。", "unsupported": "此工具檔案包含 BitFun 目前不支援的語法或操作。", @@ -333,7 +357,8 @@ }, "toolNextStep": { "approval_required": "確認上方來源與存取範圍後,啟用或繼續停用這些工具。", - "disabled": "確認上方來源與存取範圍後,再啟用這些工具。", + "declined": "確認上方來源與存取範圍後,再啟用這些工具。", + "disabled": "請先啟用此來源或其工具能力。", "active": "無需操作。若要停止使用該來源的所有工具,請停用這些工具。", "conflict": "請在下方選擇要使用的工具,或繼續保持停用。", "unsupported": "請將來源改為 BitFun 目前支援的單一 JavaScript 檔案格式,然後重新整理。",