From a1f17df6f0a786bda649c84c9a4752ccf1a633f0 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 17 Jul 2026 13:25:10 +0800 Subject: [PATCH] feat(extensions): add OpenCode standalone tool compatibility Add provider-neutral discovery, approval, conflict, and script-runtime boundaries with Desktop and CLI/TUI product flows. Keep unsupported TypeScript, package plugins, hooks, and remote execution fail-closed. --- Cargo.toml | 1 + .../external-ai-work-sources-design.md | 40 +- .../opencode-extension-compatibility.md | 52 +- .../opencode-plugin-runtime-adapter-design.md | 67 +- .../opencode-extension-compatibility-plan.md | 88 +- .../core-boundaries/rules/feature-rules.mjs | 7 +- .../rules/source/forbidden-rules.mjs | 1 + .../rules/source/public-api-rules.mjs | 85 + scripts/core-boundaries/self-test.mjs | 4 +- src/apps/cli/AGENTS.md | 9 +- src/apps/cli/src/actions.rs | 16 + src/apps/cli/src/modes/chat.rs | 1116 +++++++- src/apps/cli/src/ui/chat/popups.rs | 23 + src/apps/cli/src/ui/chat/render.rs | 10 +- src/apps/cli/src/ui/chat/state.rs | 6 +- src/apps/cli/src/ui/command_palette.rs | 1 + src/apps/cli/src/ui/startup.rs | 1 + src/apps/cli/src/ui/widgets.rs | 140 +- .../desktop/src/api/external_sources_api.rs | 57 +- .../src/api/remote_workspace_policy.rs | 8 + src/apps/desktop/src/lib.rs | 2 + .../adapters/opencode-adapter/AGENTS.md | 17 +- .../adapters/opencode-adapter/Cargo.toml | 3 +- .../adapters/opencode-adapter/src/lib.rs | 2 + .../opencode-adapter/src/tool_source.rs | 895 ++++++ .../tests/tool_source_contracts.rs | 509 ++++ .../src/agentic/execution/round_executor.rs | 33 +- .../agentic/tools/pipeline/tool_pipeline.rs | 76 +- .../agentic/tools/product_runtime/catalog.rs | 102 +- .../core/src/agentic/tools/registry.rs | 69 +- .../assembly/core/src/external_sources.rs | 1150 +++++++- .../assembly/core/src/external_tools.rs | 2530 +++++++++++++++++ src/crates/assembly/core/src/lib.rs | 2 + .../assembly/external-sources/src/lib.rs | 13 + .../assembly/external-sources/src/tool.rs | 364 +++ .../tests/tool_coordinator_contracts.rs | 163 ++ .../product-domains/src/external_sources.rs | 418 +++ .../tests/external_source_contracts.rs | 147 +- src/crates/contracts/runtime-ports/AGENTS.md | 4 + src/crates/contracts/runtime-ports/src/lib.rs | 6 + .../runtime-ports/src/script_tool.rs | 93 + .../tests/script_tool_port_contracts.rs | 156 + .../execution/tool-contracts/src/framework.rs | 12 + .../services/services-integrations/AGENTS.md | 8 + .../services/services-integrations/Cargo.toml | 6 + .../services/services-integrations/src/lib.rs | 3 + .../services-integrations/src/script_tool.rs | 976 +++++++ .../tests/script_tool_runtime.rs | 884 ++++++ .../api/service-api/ExternalSourcesAPI.ts | 94 + .../components/ExternalSourcesConfig.scss | 32 + .../components/ExternalSourcesConfig.test.tsx | 239 +- .../components/ExternalSourcesConfig.tsx | 457 ++- .../en-US/settings/external-sources.json | 87 +- .../zh-CN/settings/external-sources.json | 87 +- .../zh-TW/settings/external-sources.json | 87 +- 55 files changed, 11215 insertions(+), 243 deletions(-) create mode 100644 src/crates/adapters/opencode-adapter/src/tool_source.rs create mode 100644 src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs create mode 100644 src/crates/assembly/core/src/external_tools.rs create mode 100644 src/crates/assembly/external-sources/src/tool.rs create mode 100644 src/crates/assembly/external-sources/tests/tool_coordinator_contracts.rs create mode 100644 src/crates/contracts/runtime-ports/src/script_tool.rs create mode 100644 src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs create mode 100644 src/crates/services/services-integrations/src/script_tool.rs create mode 100644 src/crates/services/services-integrations/tests/script_tool_runtime.rs diff --git a/Cargo.toml b/Cargo.toml index 30f3d335aa..954ddaeb17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,6 +107,7 @@ aes = "0.8" hex = "0.4" chardetng = "0.1.17" encoding_rs = "0.8.35" +url = "2" # HTTP client reqwest = { version = "0.13.4", default-features = false, features = ["native-tls", "rustls", "json", "stream", "multipart", "query", "form"] } diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index a243e23aba..4257689289 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -9,8 +9,10 @@ SDK 或 Server 输出到外部宿主,以及内部 Provider Slot、状态、事 本文同时记录当前可用纵向切片与目标架构。当前 BitFun 已具备通用外部来源目录和生命周期协调器,并通过 OpenCode Prompt Command 适配器接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突, -CLI/TUI 可列出并执行 prompt-only Command。完整配置映射、Codex/Claude Code 适配器以及 Tool、Subagent、插件 -执行仍属于后续阶段,不能因来源被识别就宣称已经可用。 +CLI/TUI 可列出并执行 prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 +预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与 CLI/TUI 使用同一决策状态。完整 +TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Subagent 仍属于后续阶段,不能因来源被识别 +就宣称已经可用。 ## 1. 产品判断与竞品启示 @@ -70,7 +72,8 @@ CLI/TUI 可列出并执行 prompt-only Command。完整配置映射、Codex/Clau “已发现”或“需确认”。 - 可执行内容因首次启用、更新策略要求询问或 import 前摘要扩大而等待确认时,不得 import module、启动进程、 读取凭据或主动联网;提示可以稍后处理。 -- 当前阶段只能静态识别的插件或工具只进入“已发现,静态预览”清单,不进入模型可调用集合。 +- 当前阶段范围外的 TypeScript、依赖型 Tool 和 package plugin 只进入“已发现,静态预览”清单,不进入模型可调用 + 集合;受支持 JS Tool 也必须在明确启用前保持同一状态。 - 全局来源首次在当前执行域识别时提示一次;项目来源按工作区提示。相同全局来源不能在每个项目重复轰炸用户。 - “撤销已应用内容”对持续兼容来源表示在用户选择的当前项目或当前执行域内抑制对应来源/资产并重新计算下一 来源或产品默认;后续 watcher 更新不得绕过该偏好重新应用。该操作不写回外部文件,也不同于显式导入的字段级撤销。 @@ -265,7 +268,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 ## 8. 分阶段落地与验收 -第一阶段以 Prompt Command 做第一个可用纵向切片,不借设计提前宣称其他运行能力: +第一阶段以 Prompt Command 做第一个可用纵向切片: 1. 建立共享来源目录、生命周期协调器、开放生态 ID 和 Prompt Command 专属契约;用第二个 fake adapter 证明 provider 更新、失败和删除彼此隔离。 @@ -273,14 +276,37 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 OpenCode 自身定义的项目/用户优先级仍由 adapter 解释,跨 provider 或与 BitFun 本地 Command 的同名冲突进入待选择状态。 3. 支持 `$ARGUMENTS` 与位置参数的 prompt-only 命令在用户显式选择或输入时展开并提交;发现本身不向会话发送内容。 4. 含 `!shell`、`@file`、`{env:...}`、`{file:...}`、`agent`、`model`、`variant` 或 `subtask` 等未接通语义的命令标记为“部分受限”,不做 - 静默忽略后的部分执行。静态 custom tool 名称仍只能标为“已发现,静态预览,未执行”。 + 静默忽略后的部分执行。 5. Desktop 提供统一来源状态、刷新、按执行域抑制/恢复和冲突候选选择;首次 provider 扫描完成前显示中性检查状态, 不把暂时空目录误报为最终空结果;已经选择且指纹未变化的冲突退出待处理区。CLI/TUI 使用同一目录列出和执行 Command;跨 provider 候选以来源限定别名供 CLI 用户直接选择,同次选择也解析本地同名冲突。发现或确认不阻塞 普通聊天输入;发现未完成时未限定 slash 别名不猜测冲突结果,显式 `/builtin:` 仍可立即执行。执行域全局偏好 使用独立偏好文件、跨进程锁和严格原子替换,并在查询、刷新和执行前重新读取,使并行 Desktop/CLI 进程不会继续使用 另一进程已停用的来源或丢失并发选择。Desktop IPC 仅返回设置页所需摘要,不携带 Prompt Command 模板正文。 -6. 先完成来源变化、无效候选、稳定删除、重新出现、偏好保持和去重提示,再在后续 PR 接入真实 JS/TS Tool。 +6. 完成来源变化、无效候选、稳定删除、重新出现、偏好保持和去重提示,为后续能力 owner 提供稳定基线。 + +第二阶段以 OpenCode standalone Tool 验证可执行内容的完整产品边界: + +1. Tool provider 契约与脚本运行时端口独立于 Prompt Command;OpenCode adapter 负责全局/项目 `{tool,tools}` + 来源与格式,Core 激活策略负责审批/冲突,脚本服务负责 Node worker,现有 Tool Runtime 负责最终模型暴露和调用。 +2. 发现只读取静态摘要,不 import;首次启用显示来源文件、工作目录与直接文件/网络/环境/进程能力。确认按 target、 + 执行域、runtime 和能力集合记忆,内容更新不扩大能力时不重复询问;用户拒绝在内容更新前不再主动提示,但可在 + 设置页主动重新审核。 +3. 内置、MCP 与外部同名 Tool 使用包含候选身份和内容版本的指纹;选择前保留已有本地实现,不按注册顺序静默覆盖。 + 候选集合由已识别定义而非成功加载结果计算;候选更新、暂不可用或删除后重新选择,已选外部实现失效时保持 + unavailable,不静默回退同名本地实现。其他无关 Tool 和生态不受影响。 +4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;CLI/TUI 状态栏只做非阻塞提示,`/external-tools` + 以编号映射稳定 key 完成启用、保持停用、冲突选择和刷新。普通聊天输入和无关会话不等待用户处理。 +5. 每个 target 使用一个持久 Node worker,支持 load/invoke、`AbortSignal` 取消、500 ms 宽限后的 target 级硬终止、 + 30 秒请求期限和 dispose;产品配置的更短外层期限丢弃调用 future 时也会终止 worker,并在终止完成前保持 target + 串行许可。输出限制为 1 MiB,协议帧限制为 8 MiB。稳定删除、抑制或撤销先撤下新调用;更新加载 + 失败在没有精确物化旧源码的 PR2 中 fail-closed,不冒充沿用上一版本。worker 崩溃会立即撤下该 target 的全部 + 路由并显示失败;空闲退出由 runtime health 事件主动上报,事件携带独立加载代次,旧 worker 的迟到事件不能撤下 + 同版本的新实例。调用不自动重放;下一次模型 Tool Catalog 暴露前只尝试恢复一次,之后依赖显式刷新或来源变化, + 不形成循环重启。某个全局、项目、legacy 或显式目录不可读时只降级该目录,其他健康目录继续发现和运行。 +6. `.ts`、依赖型 `.js`、`metadata`/`ask`、附件、package plugin、Hook、TUI plugin renderer 和 Remote worker 明确 + 延后;本机 Node 进程不是 OS 沙箱,VM realm 与隐藏响应令牌只保护协议稳健性。脚本继承当前用户的文件、网络、 + 环境与进程权限;本阶段没有 Job Object/process group,脚本直接启动的后代进程和系统资源风险必须持续显示。 验收至少覆盖: @@ -300,7 +326,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 任一候选更新、删除或参与集合变化后重新进入待选择,即使变化后只剩一个实现也不静默切换。 - 冲突偏好按执行域与命令族只保留当前指纹,并以去重候选身份标记曾发生冲突;连续内容更新不会按历史指纹线性膨胀。 - 显式导入的字段级预览、冲突、撤销和凭据脱敏可验证。 -- 当前只支持静态预览的资产不会被产品文案误报为已应用或可执行。 +- 当前只支持静态预览的资产不会被产品文案误报为已应用或可执行;支持子集与完整 OpenCode 兼容不会混写。 具体 OpenCode 能力范围和近期顺序分别见 [`opencode-extension-compatibility.md`](opencode-extension-compatibility.md)和 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index f45da8523f..ec35316414 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -19,10 +19,10 @@ ## 1. 基线与判断方法 -本次清单刷新于 2026-07-16: +本次清单刷新于 2026-07-17: - 最新稳定版为 [`v1.18.3`](https://github.com/anomalyco/opencode/releases/tag/v1.18.3),提交为 [`127bdb30784d508cc556c71a0f32b508a3061517`](https://github.com/anomalyco/opencode/commit/127bdb30784d508cc556c71a0f32b508a3061517)。 -- 开发分支前瞻检查记录为提交 [`3a1c6df9e24672f0761a6ced18e1315d89334baf`](https://github.com/anomalyco/opencode/commit/3a1c6df9e24672f0761a6ced18e1315d89334baf)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 +- 开发分支前瞻检查记录为提交 [`08fb47373509ba64b13441061314eeacf4264f51`](https://github.com/anomalyco/opencode/commit/08fb47373509ba64b13441061314eeacf4264f51)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 - 配置、插件、工具、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) 为准; @@ -66,13 +66,13 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 非阻塞摘要;可执行内容在首次启用或能力扩大时等待来源/target 级确认,但不阻塞项目和无关会话。 - 设置中的统一外部来源视图负责解释全局/项目作用域、当前支持范围、待处理项和变更结果;显式导入只是把 非执行内容转为 BitFun 原生配置的可选快照,不是 OpenCode 项目可用或插件执行的前置条件。 -- 第一条执行闭环只承诺官方 `.opencode/tools/` JS/TS 来源,不要求用户重打包。单数 `tool/` 只有在冻结 - 版本源码和测试明确证明时才作为该版本兼容输入,不能写成长期公开保证。package plugin、完整配置、 - Hook 和 TUI target 在该闭环稳定后按真实样例扩展。 -- Desktop 可以先验证固定版本 Bun;HarmonyOS PC 原生 CLI/TUI 必须按 - [平台专题](../platform-portability-design.md)独立取证,不包含 HarmonyOS 手机 Remote App。只能覆盖 JS 子集的实现必须显示“能力受限”并列出 - 兼容差异,不能宣称 OpenCode runtime 完整兼容。 -- 扩展调用必须有期限、取消、有界队列、大小检查和崩溃恢复;更细的权限、沙箱和组织策略沿用现有控制点并延期 +- 第一条执行闭环已覆盖官方复数目录和源码验证过的单数目录中的受支持单文件 `.js` standalone tool;`.ts`、模块依赖、 + package plugin、完整配置、Hook 和 TUI target 仍只识别或延后。当前范围和完整兼容目标必须分别表达,不能用 + 一个 JS fixture 宣称 OpenCode runtime 完整兼容。 +- PR2 使用本机 Node.js 验证受限 JS 子集,并在 Desktop 与 CLI/TUI 显示运行时和无 OS 沙箱边界;固定 Bun、完整 + TypeScript/Zod 与包依赖仍是后续兼容目标。HarmonyOS PC 原生 CLI/TUI 必须按 + [平台专题](../platform-portability-design.md)独立取证,不包含 HarmonyOS 手机 Remote App。 +- 扩展调用必须有期限、取消、有界队列、大小检查和可观察的崩溃降级;更细的权限、沙箱和组织策略沿用现有控制点并延期 单独设计,不在首条闭环扩大接口。 - BitFun 归属模块负责最终业务状态;适配器只保留 OpenCode 的格式、顺序、参数和错误语义。 @@ -80,8 +80,8 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | 优先级 | 可观察结果 | 暂不绑定的工作 | |---|---|---| -| OC-E0 | 固定版本、官方 custom tool 契约、无外部依赖的 fixture、当前静态预览明确显示“未执行” | 全量配置导入 | -| OC-E1 | 上述 fixture 的真实 `execute` 进入现有 Tool Runtime,支持身份/路径字段和 `abort`,并在 CLI 返回结果/诊断 | `metadata`/`ask`、官方 import 型样例、package plugin、Hook、TUI | +| OC-E0 | 固定版本、官方 custom tool 契约、受支持单文件 fixture、当前静态预览明确显示“未执行” | 全量配置导入 | +| OC-E1 | 上述 fixture 的真实 `execute` 进入现有 Tool Runtime,支持身份/路径字段、合作式与硬取消,并在 Desktop/CLI/TUI 完成非阻塞审批和冲突选择 | `metadata`/`ask`、依赖型样例、package plugin、Hook、TUI 插件 API | | OC-E2 | 一个真实 package plugin,仅实现其需要的 loader 和最小 client/context | 全部 loader fallback 和 Client API | | OC-E3 | 按阻塞样例加入 Hook;TUI 先接 command/slash/key,toast 需先有 CLI 类型化状态/通知 owner | 原始 renderer、Server、Remote、连接器 | @@ -124,18 +124,19 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | OpenCode 扩展项 | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | 细节 | |---|---|---|---|---|---|---| -| `.opencode/tools/*.ts` / `*.js` | 补基础能力 | 静态名称预览 | 可完整适配 | OC-R2 | worker 保留 Zod shape/refinement 与 execute,只把模型可见 JSON Schema 传给 Rust;取消、元数据、权限请求和附件结果走类型化进程通信 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | +| `.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-工具与插件加载) | | 插件 `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 | 自动发现全局配置和 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-来源与执行版本) | -| 内置/外部顺序、pure、重复插件、同名工具覆盖 | 融合现有能力 | 未实现 | 可完整适配 | OC-R2 | 复现 internal-first、pure 跳过 external、来源顺序、去重与覆盖;仅允许显式策略保护极少数产品关键项 | [注册与覆盖](opencode-plugin-runtime-adapter-design.md#53-注册与覆盖) | -| `project` / `directory` / `worktree` | 直接桥接 | 未实现 | 可完整适配 | OC-R2 | 传递真实执行域身份与路径;Remote 在 OC-R5 前返回 `unsupported` | [兼容门面](opencode-plugin-runtime-adapter-design.md#7-opencode-兼容门面) | +| 内置/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 | 建立来源限定身份、target 状态、后台候选、健康旧进程保留和可验证的精确版本恢复 | [生命周期](opencode-plugin-runtime-adapter-design.md#9-生命周期与能力暴露) | +| 加载、停用、更新与崩溃恢复 | 补基础能力 | 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-故障域) | 本类整体风险是第三方代码副作用、依赖安装失败、Hook 顺序漂移和 worker 失控。默认权限可以开放,但执行隔离、超时、取消、队列上限、结果大小和故障恢复必须始终启用。 @@ -225,10 +226,13 @@ OpenCode 发布新稳定版时按以下顺序升级: ConfigPaths 中各配置/插件目录;目录自动发现只适用于服务插件,TUI target 必须出现在合并后的 `tui.json/jsonc` `plugin` 列表。发现本身不授予执行资格。 - 当前能够安全消费的非执行内容按用户的“自动应用低风险内容 / 先询问”偏好处理。默认自动应用并显示一次 - 可撤销摘要;当前只能静态识别的插件或工具只显示“已发现,静态预览,未执行”。 + 可撤销摘要;PR2 支持范围内的 JS standalone Tool 在确认前显示“已发现,静态预览,未执行”,范围外 Tool + 显示稳定不支持原因,不能进入 worker。 - 可执行插件、Tool、Hook 和 TUI target 的来源级加载偏好按“来源限定身份 + target + 执行域 + 更新策略”确认, 项目执行实例再按有效来源图、工作目录/环境、执行包络和能力摘要重新求值。确认是非阻塞待办,不弹出阻止项目进入 的信任向导;同一有效实例摘要下的准备、worker 启动和贡献注册不再逐层重复询问。 +- 当前内置/MCP 候选指纹基于 Tool Catalog 已公开的身份、描述和 schema;若实现行为变化但这些摘要完全不变,PR2 + 不会主动重问。后续若能力 owner 提供稳定 revision,应纳入候选指纹,而不是让 Core 猜测实现版本。 - 第三方模块 import 前,仍须依据来源身份/内容版本、target、实际执行域/用户、产品/组织策略上限、凭据和 环境范围重新计算当前有效策略与安全启动参数,不能复用发现期或另一执行域的决定。任何直接脚本副作用都不能 发生在确认和 import 前重算之前。 @@ -254,6 +258,22 @@ OpenCode 发布新稳定版时按以下顺序升级: 代码或依赖更新失败时可以保留健康旧进程;重建旧代次必须有摘要匹配的精确物化目录。显式停用、删除、来源撤销、 权限收紧或安全策略失效必须先停止新调用并撤下旧贡献,不能恢复到不再合规的旧状态。 +上述是完整 Host 目标。PR2 standalone Tool 尚未物化不可变旧源码,因此原位文件更新后的 load 失败会撤下旧 worker +并显示 `load_failed`,而不是从已变化文件重建并冒充上一版本。该 fail-closed 行为只影响对应 target,不影响同来源 +Command、其他 Tool target 或其他生态 adapter。 + +PR2 对未变化且仍健康的 target 保留原 worker 和模块状态;变化、停用或删除的 target 在慢速准备前先撤下路由和 +worker。授权在准备前、准备后 import 前、load 后注册前和每次 invoke 前重读,缩小 Desktop/CLI 跨进程撤销窗口; +跨进程文件偏好与已进入脚本执行之间仍不可能形成数据库式原子事务,已经发出的调用不会被回溯撤销。worker 崩溃 +会立即撤下该 target 路由并标记 `load_failed`,不回退同名内置/MCP 实现,也不自动重放;下一次 Tool Catalog 暴露前 +只消费一次恢复预算,仍失败则等待显式刷新或来源变化,不形成重启风暴。 + +CLI/TUI 更新订阅在活动期间持有工作区服务;Desktop/Agent 每次装配模型可见 Tool Catalog 时续期并在首次或空闲 +回收后同步刷新。首次后台刷新与 catalog 装配共享同一个完成门闩:catalog 等待在途结果,失败后允许下一次装配重试。 +没有订阅或目录装配活动的工作区在 5 分钟后撤下路由并回收 worker,避免依次打开多个项目后永久累积 Node 进程; +下一次目录装配会在向模型暴露前恢复仍获批准且仍有效的 route。Remote catalog 与执行解析显式 fail-closed,即使远端 +路径文本与本机工作区相同也不会复用本机 route/worker。 + | 变化 | 用户体验 | |---|---| | 已激活项目中的同一本地文件变化,更新策略允许且能力集合不变 | 后台重载,在下一次安全调用边界切换;一级状态短暂显示“更新中”。 | diff --git a/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md b/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md index 5aadb49946..2dee3b55ac 100644 --- a/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md +++ b/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md @@ -12,8 +12,10 @@ [`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/tool.ts) 和实际插件 loader/npm 服务为准;[插件文档](https://opencode.ai/docs/plugins/)用于行为说明。 -本文是目标设计。当前实现结束于 BitFun 专用目录来源、启用记录、静态 custom tool 名称预览和诊断,尚未包含脚本执行进程、 -OpenCode 兼容 Client、真实工具调用或稳定钩子执行。 +本文同时区分当前纵向切片与完整目标。当前实现已支持用户/项目 standalone `.js` Tool 的静态发现、非阻塞审批、 +Node worker `load/invoke/cancel/dispose`、现有 Tool Runtime 注册、同名冲突选择与更新/删除撤下;`.ts` 和依赖型 JS +只显示不支持。固定 Bun、完整 Zod/TypeScript、package plugin、OpenCode 兼容 Client 和稳定 Hook 仍是目标设计, +不能把 PR2 的 Node 子集写成完整运行时兼容。 ## 1. 核心决策 @@ -36,6 +38,10 @@ BitFun 实现自己的插件 Runtime,不启动完整 OpenCode Runtime: Rust 能力转发和状态恢复;并不表示重写 Bun,也不表示在后台启动 OpenCode 的 Agent Runtime。插件工厂和钩子 在 Bun worker 中运行,需要 BitFun 能力时通过兼容接口转发给 Rust 归属模块。 +PR2 的 `ScriptToolRuntime` 是 provider-neutral 的窄端口,当前 Node 实现只用于受支持的单文件 JavaScript standalone Tool。 +它不把 Node 固化进 Host ABI,也不承担 OpenCode 格式、审批或冲突规则;后续 Bun 实现可以替换物理执行服务而 +无需修改 Tool owner、产品表面或其他生态 adapter。 + “最终由归属模块提交”不是把 OpenCode 可写钩子降级成只读通知。默认策略允许合法钩子变换生效;归属模块只负责 结构校验、状态一致性和已配置策略,不改变 OpenCode 的正常可观察语义。 @@ -207,14 +213,38 @@ BitFun 原生 `bitfun.plugin.json` 包继续使用现有来源校验;OpenCode ### 5.1 Standalone tools -- 扫描项目和用户级 `.opencode/tools/*.ts|js`,兼容 singular 目录;扫描只形成来源清单,首次确认前不 import。 -- 真实加载 default 和多个 named export;按 OpenCode 规则生成 `_` 工具名。 -- 工具的原始 Zod shape、refinement 和 `execute` 留在该工具进程;worker 用原始 Zod `safeParse` 校验调用。 -- worker 只把工具 identity、description 和转换后的 JSON Schema 传给 Rust,不能序列化 Zod 或函数来伪装等价。 -- `ToolContext` 的 session/message/agent/directory/worktree 取真实执行上下文;`abort` 接收 Host 取消信号; - `metadata({ title?, metadata? })` 与 `ask()` 通过反向类型化调用更新流式元数据和请求权限。 -- ToolResult 的 title/output/metadata 与 `attachments: { type: "file", mime, url, filename? }[]` 逐项转换; - 附件 URL 只能指向插件执行域可授权读取的文件或受支持地址,不能把本地路径静默解释为远端文件。 +当前 PR2 边界: + +- 扫描用户全局、legacy、显式配置目录和项目层级 `{tool,tools}/*.ts|js`;扫描只形成静态来源/工具清单,首次确认 + 前不 import。`.ts`、非精确 `@opencode-ai/plugin` import、其他 import、动态 import 和 `require` 标记不支持。 +- 受支持的单文件 `.js` 支持 default 和多个 named export,按 `` / `_` 命名;“单文件”是当前 + loader 兼容边界,不是安全结论。worker 内基础 schema shim 覆盖 string/number/boolean/enum/array/object、 + description、optional/default,以及按类型映射的 min/max,不宣称等价完整 Zod。 +- worker 只返回 identity、description、JSON Schema 和字符串输出;`directory/worktree/sessionID/abort` 使用真实调用 + 上下文。`metadata`、`ask`、附件和任意对象结果不支持,避免无消费方的泛协议扩张。 +- 每个 target 使用一个持久 worker 并保留其模块单例状态;合作式取消先触发 `AbortSignal`,脚本阻塞事件循环时 + 500 ms 后终止整个 target worker。普通请求 30 秒超时后也终止 worker;更短的产品 Tool 期限丢弃调用 future 时, + runtime drop guard 同样终止 worker,并在退出完成前保留单 target 串行许可。不自动重放调用;输出为 1 MiB,单协议帧 + 为 8 MiB,无法认证的 stdout 累计到 1 MiB 后终止 worker。`import.meta.url` 使用已校验的来源 URL。 +- 当前进程不是 OS 沙箱。模块使用独立 VM realm 且协议响应令牌不经 stdin 暴露,只用于降低偶然协议破坏,不能阻止 + 已批准脚本获得当前用户的文件、网络、环境和进程能力,也不构成针对恶意模块的协议认证;获批脚本控制其 target + 内的执行语义。本阶段没有进程树/Job Object;硬取消只保证直接 worker 退出,其后代进程和已创建系统资源可能 + 存活,必须在 Desktop 与 CLI/TUI 确认详情持续披露。 +- global、project、legacy 或 `OPENCODE_CONFIG_DIR` 任一目录读取失败时记录绑定该 source 的诊断并继续扫描其他目录, + 不把单目录 I/O 故障升级成整个 OpenCode Tool provider 不可用。 +- invoke 超时、空闲/调用中 worker 退出或 cancel 硬终止会通过带独立加载代次的 runtime health 事件立即撤下 target + 的全部路由并进入 `load_failed`;旧 generation 的迟到退出不能误伤同内容的新 worker。调用不重放,下一次 Tool + Catalog 暴露前只进行一次恢复尝试,失败后等待显式刷新或来源变化。零 route mux 继续作为 registry 稳定拦截点, + 防止并发内置/MCP 注册绕过同名冲突策略。 + +完整目标在不改变 provider/owner 边界的前提下继续补齐: + +- 固定 Bun 真实加载 JS/TS、依赖、default/具名导出;原始 Zod shape、refinement 和 `execute` 留在 worker, + worker 用原始 Zod `safeParse` 校验调用,只把模型可见 JSON Schema 传给 Rust。 +- `ToolContext` 补齐 message/agent 等真实执行上下文;`metadata({ title?, metadata? })` 与 `ask()` 通过反向类型化 + 调用更新流式元数据和请求权限。 +- ToolResult 的 title/output/metadata 与 `attachments: { type: "file", mime, url, filename? }[]` 逐项转换;附件 + URL 只能指向插件执行域可授权读取的文件或受支持地址,不能把本地路径静默解释为远端文件。 - 任意语言包装继续由插件代码启动子进程;Host 不另建语言专用工具 ABI。 ### 5.2 服务插件 @@ -249,7 +279,9 @@ BitFun 原生 `bitfun.plugin.json` 包继续使用现有来源校验;OpenCode - 运行时新增或缺失工具只更新本次真实贡献和差异诊断。 - 用户策略对能力类别有限制时,超出上限的贡献被拒绝,其他贡献继续注册。 -- 同名插件工具默认按 OpenCode 语义覆盖内置工具;原工具保留可诊断身份和可选别名。 +- BitFun 产品边界优先于 OpenCode 的静默覆盖顺序:同名外部 Tool、BitFun 内置和 MCP 候选形成包含身份与内容版本 + 的冲突指纹;候选集合来自静态识别定义,不因某个 worker 暂不可用而消失。用户选择前保留当前本地实现;显式选择 + 外部实现后,任一候选变化或加载失败都保持 fail-closed 并要求重选,不静默回退。生态内原始顺序仍由 OpenCode adapter 解释。 - 产品或组织可保护少量安全关键工具。保护冲突必须在加载状态中可见,不能静默改名或丢弃。 - 工具调用继续进入现有可调用工具集合、期限、取消和结果类型;不新增只供插件使用的第二套调用状态机。 @@ -426,13 +458,16 @@ discovered -> pending-preparation -> preparing -> ready -> pending-activation -> - 当前执行版本、依赖缓存和 worker 健康由远端 Runtime Services 管理。 - 连接中断时调用返回 `temporarily-unavailable`,恢复后重新协商版本和当前注册项。 -## 11. 当前实现迁移 +## 11. 当前实现与后续迁移 + +PR2 已把 standalone Tool 放在目标分层的正确位置:通用契约位于 product domains/runtime ports,OpenCode 路径与 +格式位于独立 adapter,来源代次位于 external-sources coordinator,审批/冲突/工作区路由位于 Core,物理 Node +进程位于 script execution service,Desktop/CLI/TUI 只消费快照和操作。后续 adapter 不依赖 OpenCode 私有类型。 -现有 P0-C.1/P0-C.2 保留以下成果:BitFun 专用包的来源完整性、状态诊断、启停版本校验、主机期限、故障暂停 -和静态名称预览测试。需要纠正: +当前仍需保留并逐步迁移的边界: - `bitfun.plugin.json` 不是所有 OpenCode 插件的强制作者格式。 -- 静态候选不是工具真实定义,不能要求运行时集合与字符串扫描完全相同。 +- 静态候选不是完整工具真实定义;PR2 在 load 时核对预期 export 并 fail-closed,完整 Host 后续比较动态贡献差异。 - 无 custom tool 的插件仍可因 Hook、auth 或 provider 被启用。 - `client/server facade` 不再一律拒绝;改为插件专用 Compatibility Facade。 - 可写 Hook 不再一律拒绝;改为依次变换、结构校验和归属模块提交。 @@ -440,6 +475,8 @@ discovered -> pending-preparation -> preparing -> ready -> pending-activation -> 变化时确认,同一摘要下的内部生命周期不重复询问。 - 泛 `PluginDispatchEnvelope/PluginEffectCandidate` 不继续扩张承载所有调用;实施时拆成 source lifecycle、 worker invoke 和 typed hook transform 等窄路径。 +- PR2 原位本地文件没有精确旧物化目录,更新 load 失败会撤下对应 target;只有未来保存摘要匹配的精确字节后, + 才能实现“健康旧进程继续服务”而不从新源码伪造旧版本。 ## 12. 验证要求 diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index 631d0d28f3..f47d89d1f7 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -2,7 +2,9 @@ 本文定义 OpenCode 兼容能力的近期交付顺序。完整能力差异保留在 [兼容矩阵](../architecture/extensions/opencode-extension-compatibility.md),跨生态来源体验与生命周期见 -[外部 AI 工作内容设计](../architecture/extensions/external-ai-work-sources-design.md)。兼容矩阵是审计库存,不是默认路线图。 +[外部 AI 工作内容设计](../architecture/extensions/external-ai-work-sources-design.md),通用能力 owner、Generation 与宿主边界见 +[能力装配与宿主集成设计](../architecture/extensions/capability-runtime-integration-design.md)。本计划只覆盖外部 OpenCode +能力进入 BitFun 的渐进导入轨道,不代表 BitFun 能力导出到 OpenCode 已经完成。兼容矩阵是审计库存,不是默认路线图。 PR1 已将基线推进到通用外部来源目录、生命周期协调器和 OpenCode Prompt Command 纵向切片;BitFun 原有受管 插件包来源确认和 custom tool 静态预览继续保留。后续不沿用“先做一个大而全的 OpenCode Plugin Runtime”路线, @@ -18,7 +20,7 @@ Product surfaces (Desktop / CLI / TUI) -> External Source Catalog / lifecycle coordinator -> consumes capability-specific provider contracts -> Prompt Command provider contract - -> future Tool provider contract + -> Tool provider contract + provider-neutral script runtime port -> future Subagent provider contract Same-level ecosystem adapters implement those provider contracts @@ -58,7 +60,7 @@ Product Assembly registers adapter implementations with the coordinator | PR | 用户可观察结果 | 新增能力 owner | 明确不包含 | |---|---|---|---| | PR1:来源目录 + OpenCode Command | Desktop 可查看、抑制/恢复并刷新全局/项目 OpenCode 来源;CLI/TUI 可列出并执行支持的 `/command`;运行中修改、删除、恢复后自动刷新 | 通用来源目录与生命周期协调器;Prompt Command 契约;OpenCode Command adapter | JS/TS Tool 执行、Hook、MCP、OpenCode Client/Server、Subagent 执行、复制式导入 | -| PR2:OpenCode standalone Tool | 一个真实、无外部依赖的 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | +| PR2:OpenCode standalone Tool | 一个真实、受支持的单文件 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | | PR3:OpenCode Subagent | 全局/项目 agent 定义进入现有 Subagent owner,可选择、调用、更新和撤下;unsupported 字段有明确诊断 | 现有 Subagent owner + 独立 Subagent 兼容接口 | 原始 OpenCode 会话内核、完整 primary-agent 替换、跨产品通用 agent JSON | Tool 与 Subagent 不复用 Command 的贡献对象,只复用来源身份、状态、代次、诊断和观察生命周期。未来接入 Codex 或 @@ -135,13 +137,79 @@ Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修 ## 4. PR2:OpenCode standalone Tool -PR2 只在 PR1 的来源限定身份、生命周期和产品状态稳定后启动。它从 workspace/user 官方目录发现 tool,不要求 -`bitfun.plugin.json`,并把一个真实、无外部依赖且不调用 `metadata`/`ask` 的契约样例接入现有 Tool Runtime。 - -- 首次按来源/target/执行域说明代码来源、工作目录和直接文件/网络/进程能力;确认前不 import 或启动 worker。 -- worker 只提供真实 load/invoke/cancel/dispose 和诊断;Tool Runtime 继续负责 schema、权限、排队、审计与结果。 -- 更新只有在来源身份、完整性、执行包络和更新策略仍有效时自动准备;能力扩大进入非阻塞 `action-required`。 -- 删除或撤销立即撤下新调用;失败只影响对应 target,不影响 Command、Subagent 或其他生态。 +PR2 在 PR1 的来源目录上新增独立 Tool provider 契约和 provider-neutral 脚本运行时端口。OpenCode adapter 只解释 +OpenCode 路径、命名和模块格式;Core 只消费通用 Tool 快照、审批与冲突契约;脚本服务只负责物理 worker。未来 +Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCode adapter,也不让 Tool Runtime 按生态分支。 + +### 4.1 本阶段可用范围 + +- 自动发现用户全局、legacy、`OPENCODE_CONFIG_DIR` 和项目层级的 `{tool,tools}/*.js`、`*.ts`。扫描有文件数与 + 单文件大小上限,且只读取静态摘要;发现阶段不 import 模块、不解析依赖、不启动进程。 +- `.js` 仅支持单文件 loader 子集:默认导出或具名导出的 `tool({...})`/对象定义、基础 schema shim、字符串结果或 + `{ output: string }`。只允许精确的 `@opencode-ai/plugin` `tool` import;其他静态/动态 import、`require`、 + package plugin 和依赖安装明确显示为不支持。 +- `.ts` 当前只识别来源、名称和不可用原因,不执行;完整 TypeScript、Bun、Zod refinement、`metadata`、`ask`、 + 附件结果、插件 `tool` map 和 Hook 延后。Node.js 不可用时工具保持可见但不激活。 +- 用户确认后,每个 target 启动独立 Node.js worker,提供 `load/invoke/cancel/dispose`。合作式取消先传递 + `AbortSignal`;脚本阻塞事件循环时在短宽限期后终止整个 target worker。Tool 的 schema、调用权限、审计和 + 模型暴露继续走现有 Tool Runtime,不建立第二套路由。 + +### 4.2 产品与决策语义 + +- Desktop 在“外部 AI 应用”中显示来源、文件、工作目录、工具名和直接文件/网络/环境/进程能力,并明确提示当前 worker + 不是 OS 沙箱。CLI/TUI 使用同一快照:状态栏只做一次非阻塞提醒,`/external-tools` 提供静态预览以及 + `enable`、`disable`、`choose`、`refresh` 操作;等待处理不阻塞输入或普通会话。 +- 首次启用键由“来源限定 target + 执行域 + runtime + 能力集合”组成。纯内容更新且能力集合不变时复用已批准 + 结果;能力、runtime 或执行域扩大时重新确认。用户选择保持停用后,同一内容版本不再主动询问;来源内容更新后 + 才形成新 decision key。Desktop 仍允许用户主动重新审核,避免“一次拒绝后永久不可恢复”。 +- 外部 Tool 与 BitFun 内置、MCP 或其他外部 Tool 同名时,不按 adapter 或注册顺序静默覆盖。冲突键包含全部候选 + 身份与内容版本;候选来自静态识别定义而不是成功加载集合。选择前保留已有本地实现,选择后只在候选集合和版本 + 不变时复用;任一候选更新、删除或暂不可用后重新询问,已选 external 失效期间不回退同名内置/MCP。 +- Desktop IPC 和 TUI 快照只包含静态摘要与决策 key,不传输模块源码。用户选择落盘使用 PR1 的跨进程锁、锁内 + 合并和原子替换;Desktop 与 CLI/TUI 不分别维护偏好。 + +### 4.3 更新、删除与降级 + +1. watcher 对全局与项目 Tool 目录去抖后重扫;准备前再次发现并核对 target 和内容版本,预览后被替换的文件按 + stale revision 拒绝,不能执行未确认的新内容。 +2. 已批准 target 在来源身份、runtime 和能力集合不变时后台重载;导出、schema 或 load 校验失败时撤下该 target, + 显示 `load_failed`,不保留对已变化原位源码的旧 worker。PR2 尚无精确物化旧版本,因此选择安全的 fail-closed, + 不伪装成“沿用上一版本”。 +3. 稳定删除、来源抑制、主动停用或审批撤销会撤下 Tool Runtime 路由并 dispose worker;在途调用先收到取消,阻塞 + worker 在宽限期后终止。一个 target 的失败不得清空同来源 Command、其他 target 或其他生态 adapter。 +4. 每 target 持久 worker 的普通请求 30 秒无响应时终止;合作取消在 500 ms 后硬终止,输出限制 1 MiB、协议帧 + 限制 8 MiB;产品配置的更短 Tool 期限丢弃调用 future 时也会终止 worker,并在终止完成前保持串行许可。调用不 + 自动重放。Node 进程仍以当前用户权限运行;VM realm 和隐藏响应令牌不是安全 + 沙箱,本阶段也没有进程树/Job Object,直接创建的后代进程和系统资源不保证被回收,产品必须持续显示残余风险。 + worker 丢失由带独立加载代次的 runtime health 事件立即撤下路由并显示失败;旧 worker 的迟到事件不能撤下同内容 + 新实例。下一次 catalog 暴露前仅恢复一次,失败后等待显式刷新或来源变化;不回退 + 同名内置/MCP 实现,也不形成自动重启循环。单个来源目录不可读只降级该目录,其他健康目录继续生效。 +5. Remote 工作区在有远端发现、偏好和 worker owner 前明确不支持,catalog、批处理策略和执行解析均 fail-closed, + 即使远端与本机路径文本相同也不能回退加载本机全局或项目 Tool。 + +### 4.4 PR2 验证门槛 + +- 契约测试证明静态快照不携带模块源码,审批只在能力/执行域变化时失效,冲突在任一候选内容变化时失效。 +- OpenCode fixture 覆盖默认全局目录追加 `OPENCODE_CONFIG_DIR`、项目与单复数目录、默认/具名导出、单文件 JS + 子集、schema 默认值/类型化 min/max、`import.meta.url`、TS/依赖/动态 import 降级、文件上限和“发现不执行代码”。 +- 脚本运行时覆盖 load/invoke、内容更新、失败更新撤下、合作式取消、阻塞事件循环硬终止和 dispose。 +- Tool 路由覆盖内置/MCP/多外部候选冲突、按工作区选择、候选更新重问、稳定删除、源级隔离、零 route mux 并发注册 + 和 worker-lost 撤路由/单次恢复;首次后台刷新与 catalog 竞态覆盖等待、成功复用和失败重试。 +- Desktop 覆盖非阻塞发现、首次审批、主动重新审核、停用、冲突选择和不可用原因;CLI/TUI 覆盖提示去重、编号到 + 稳定 key 的映射、过期选择拒绝和刷新。通过相关 Rust tests、CLI check/tests、Web focused tests、 + `type-check:web`、i18n audit、repo hygiene 与 desktop/core checks。 + +### 4.5 后续收敛项 + +以下改进不回补到 PR2;只有真实故障、指标或下一能力切片证明需要时,才作为独立小 PR 进入,不能借此提前建设 +完整插件主机或通用信任中心: + +- 将 route、并发属性和 timeout owner 冻结为单个 invocation plan,消除并发切换期间的二次读取窗口。 +- 为来源目录瞬时不可读增加类型化 `unknown/last-good` 状态;只有精确物化内容仍可校验时才继续服务,明确删除、 + 停用和权限收紧仍立即撤下。 +- 为保留的零 route mux 增加数量/命中指标;只有证明长期积累后再设计不重新引入注册竞态的安全回收。 +- 统一 Desktop 与 CLI/TUI 的 `load_failed` 恢复文案,并让手动刷新产生的新 diagnostics 保持非阻塞可见;不因此 + 引入跨 GUI/TUI 组件协议。 ## 5. PR3:OpenCode Subagent diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 0a114a2a83..48a26b1d63 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -48,7 +48,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'workspace-search'], + ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'workspace-search'], }, { depName: 'base64', @@ -56,7 +56,7 @@ export const optionalDependencyFeatureOwnerRules = [ }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research'] }, { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'miniapp-runtime', 'plugin-source'] }, - { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, + { 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'], @@ -96,7 +96,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'tokio-util', ownerFeatures: ['remote-ssh'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'remote-connect', 'review-platform'] }, { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'workspace-search'] }, + { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, { depName: 'windows', ownerFeatures: ['plugin-source', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], @@ -189,6 +189,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'remote-ssh', 'remote-ssh-concrete', 'review-platform', + 'script-tool-runtime', 'web-tools', 'workspace-search', ], diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 6660cbba1f..a66b274ac0 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4116,6 +4116,7 @@ export const forbiddenContentUnderRules = [ allowPaths: [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', ], diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 1d3015a2a9..0f12f3e5f2 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -6,6 +6,7 @@ export const publicApiContractSlices = [ 'plugin-runtime-internal-abi', 'opencode-adapter-boundary', 'external-source-command-contract', + 'external-source-tool-contract', ]; const contractSlices = { @@ -14,6 +15,7 @@ const contractSlices = { pluginRuntimeInternalAbi: 'plugin-runtime-internal-abi', opencodeAdapterBoundary: 'opencode-adapter-boundary', externalSourceCommandContract: 'external-source-command-contract', + externalSourceToolContract: 'external-source-tool-contract', }; function pluginRuntimeEntry(symbol, p0, consumer, verification, contractSlice, wireImpact = true) { @@ -182,6 +184,14 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeCommandProviderOptions', 'OpenCode command adapter fixture tests and explicit environment injection', ), + opencodeAdapterEntry( + 'OpenCodeToolProvider', + 'bitfun-core external source composition root and OpenCode standalone-tool adapter tests', + ), + opencodeAdapterEntry( + 'OpenCodeToolProviderOptions', + 'OpenCode standalone-tool adapter fixture tests and explicit environment injection', + ), ]; function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { @@ -200,6 +210,22 @@ function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { }; } +function externalToolEntry(symbol, owner, consumer, wireImpact = false) { + return { + symbol, + owner, + consumer, + verification: + 'external tool contract, coordinator, OpenCode adapter, worker runtime, core routing, CLI, and Desktop tests', + p0: 'PR2 ecosystem-neutral standalone-tool activation and OpenCode JavaScript vertical slice', + contractSlice: contractSlices.externalSourceToolContract, + wireImpact, + rationale: + 'PR2 needs typed preview, approval, conflict, activation, and preparation contracts without ecosystem payload leakage', + exit: 'remove only through a reviewed tool-capability contract migration with equivalent isolation and product tests', + }; +} + export const externalSourceContractPublicApiEntries = [ 'ExternalSourceContractError', 'SourceKey', @@ -232,6 +258,36 @@ export const externalSourceContractPublicApiEntries = [ 'ecosystem command providers, external-source coordinator, product composition, and neutral product surfaces', true, ), +).concat( + [ + 'SourceQualifiedToolTargetId', + 'SourceQualifiedToolId', + 'ExternalToolRuntimeKind', + 'ExternalToolCapability', + 'ExternalToolStaticStatus', + 'ExternalToolDefinition', + 'external_tool_approval_key', + 'external_tool_conflict_key', + 'external_tool_decision_key', + 'ExternalToolProviderIdentity', + 'ExternalToolProviderSnapshot', + 'PreparedExternalToolExport', + 'PreparedExternalToolTarget', + 'ExternalToolSourceProvider', + 'ExternalToolActivationState', + 'ExternalToolCatalogEntry', + 'ExternalToolApprovalRequest', + 'ExternalToolConflictCandidateKind', + 'ExternalToolConflictCandidate', + 'ExternalToolConflict', + ].map((symbol) => + externalToolEntry( + symbol, + 'product-domains external tool contract owner', + 'ecosystem tool providers, external-tool coordinator, product composition, and neutral product surfaces', + true, + ), + ), ); export const externalSourceCoordinatorPublicApiEntries = [ @@ -247,6 +303,18 @@ export const externalSourceCoordinatorPublicApiEntries = [ 'bitfun-core bounded concurrent provider scheduler', ), ), + ...[ + 'ExternalToolCoordinator', + 'ExternalToolCoordinatorSnapshot', + 'ExternalToolDiscoveryRequest', + 'ExternalToolDiscoveryResult', + ].map((symbol) => + externalToolEntry( + symbol, + 'external-sources assembly owner', + 'bitfun-core bounded concurrent external-tool provider scheduler', + ), + ), ]; export const externalSourceCorePublicApiEntries = [ @@ -255,6 +323,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalSourceCatalogEntry', 'ExternalSourceCatalogSnapshot', 'ExternalSourceDiagnostic', + 'ExternalSourceDiagnosticSeverity', 'ExternalSourceLifecycleState', 'PromptCommandAvailability', 'PromptCommandCatalogEntry', @@ -276,6 +345,22 @@ export const externalSourceCorePublicApiEntries = [ 'bitfun-cli and desktop host APIs', ), ), + ...[ + 'ExternalToolActivationState', + 'ExternalToolApprovalRequest', + 'ExternalToolCapability', + 'ExternalToolCatalogEntry', + 'ExternalToolConflict', + 'ExternalToolRuntimeKind', + 'set_external_tool_target_decision', + 'set_external_tool_conflict_choice', + ].map((symbol) => + externalToolEntry( + symbol, + 'bitfun-core external tool composition facade', + 'bitfun-cli and desktop host APIs', + ), + ), ]; function pluginSourceEntry(symbol, owner, consumer, verification, wireImpact) { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index b1b0085413..2a7fc5488f 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -955,10 +955,10 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions' + 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions' ) { throw new Error( - 'OpenCode adapter public API budget must stay limited to the reviewed package factory and command provider surface', + 'OpenCode adapter public API budget must stay limited to the reviewed package factory, command provider, and standalone-tool provider surfaces', ); } for (const entry of opencodeAdapterPublicApiRule.allowedSymbolEntries) { diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 8be84fc1e7..dc6510b76d 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -40,7 +40,14 @@ before product-definition, TUI layout, branding, packaging, runtime, or plugin a - OpenCode Prompt Commands from standard user and project configuration are read-only live sources. CLI may execute only the expanded prompt through the existing agent owner; it must re-confirm changed conflict participants and - must not execute OpenCode plugin code, tools, hooks, or subagents. + must not execute shell/file directives that the prompt-command contract marks + unsupported. +- OpenCode standalone JavaScript tools may execute only through the shared + external-source approval, conflict, Tool Runtime, and script-worker owners. + CLI/TUI consumes typed snapshots and actions; it must not import modules, + spawn tool workers, bypass a pending decision, or implement a second approval + store. TypeScript, dependency loading, package plugins, hooks, and subagents + remain non-executable until their own reviewed capability slice lands. - The managed-package OpenCode adapter remains a static-preview path. Other OpenCode plugin capabilities, Codex, and Claude remain import/reference sources unless their own reviewed adapter design explicitly changes. Never copy diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index afdf2e593b..4bfe1af452 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -66,6 +66,7 @@ pub(crate) enum ActionHandler { ReloadSkills, Subagents, McpServers, + ExternalTools, AcpHelp, Init, History, @@ -368,6 +369,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: true, }, + ActionSpec { + id: "external_tools", + name: "External tools", + aliases: &["/external-tools"], + description: "Review compatible tools from external AI applications", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ExternalTools, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Extensions", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "acp_help", name: "ACP setup", diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index c00efbff01..691e2861eb 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -9,7 +9,10 @@ use crossterm::event::{ }; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{ + mpsc::{self, Receiver, TryRecvError as MpscTryRecvError}, + Arc, +}; use std::time::{Duration, Instant}; use tokio::sync::broadcast::error::TryRecvError; @@ -58,8 +61,11 @@ use bitfun_core::agentic::tools::implementations::skills::{ use bitfun_core::external_sources::{ expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, prompt_command_conflict_key, remember_external_source_conflict_choice, - set_external_prompt_command_conflict_choice, subscribe_external_source_updates, - ExternalSourceCatalogSnapshot, PromptCommandAvailability, + set_external_prompt_command_conflict_choice, set_external_tool_conflict_choice, + set_external_tool_target_decision, subscribe_external_source_updates, + ExternalSourceCatalogSnapshot, ExternalSourceDiagnosticSeverity, ExternalToolActivationState, + ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolRuntimeKind, + PromptCommandAvailability, }; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::{ @@ -235,6 +241,587 @@ fn external_command_counts(snapshot: &ExternalSourceCatalogSnapshot) -> (usize, }) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExternalToolReviewAction { + Show, + Refresh, + Decide { + approval_key: String, + decision_key: String, + approved: bool, + }, + Choose { + conflict_key: String, + candidate_id: String, + }, +} + +struct ExternalToolMutationResult { + action: ExternalToolReviewAction, + result: std::result::Result, +} + +struct ExternalToolTargetSummary<'a> { + tools: Vec<&'a ExternalToolCatalogEntry>, +} + +impl<'a> ExternalToolTargetSummary<'a> { + fn first(&self) -> &'a ExternalToolCatalogEntry { + self.tools[0] + } + + fn activation(&self) -> &'a ExternalToolActivationState { + &self.first().activation + } + + fn names(&self) -> String { + let mut names = self + .tools + .iter() + .map(|tool| tool.definition.name.as_str()) + .collect::>(); + names.sort_unstable(); + names.dedup(); + names.join(", ") + } +} + +fn external_tool_target_summaries( + snapshot: &ExternalSourceCatalogSnapshot, +) -> Vec> { + let mut summaries: Vec> = Vec::new(); + for tool in &snapshot.tools { + if let Some(summary) = summaries + .iter_mut() + .find(|summary| summary.first().definition.id.target == tool.definition.id.target) + { + summary.tools.push(tool); + } else { + summaries.push(ExternalToolTargetSummary { tools: vec![tool] }); + } + } + summaries +} + +fn external_tool_activation_label(activation: &ExternalToolActivationState) -> &'static str { + match activation { + ExternalToolActivationState::ApprovalRequired => "approval required", + ExternalToolActivationState::Disabled => "disabled", + ExternalToolActivationState::Active => "active", + ExternalToolActivationState::Conflict => "name conflict", + ExternalToolActivationState::Unsupported { .. } => "unsupported", + ExternalToolActivationState::RuntimeUnavailable { .. } => "runtime unavailable", + ExternalToolActivationState::LoadFailed { .. } => "load failed", + _ => "unknown", + } +} + +fn external_tool_scope_label(scope: impl std::fmt::Debug) -> &'static str { + match format!("{scope:?}").as_str() { + "UserGlobal" => "user global", + "Project" => "project", + "WorkspaceLocal" => "workspace local", + "RemoteUser" => "remote user", + "RemoteProject" => "remote project", + _ => "unknown", + } +} + +fn external_tool_user_facing_reason(reason: &str) -> String { + reason + .replace("PR2 worker", "Tool process") + .replace("PR2", "This version") +} + +fn external_tool_reason(summary: &ExternalToolTargetSummary<'_>) -> Option { + match summary.activation() { + ExternalToolActivationState::Unsupported { reason } + | ExternalToolActivationState::RuntimeUnavailable { reason } + | ExternalToolActivationState::LoadFailed { reason } => { + Some(external_tool_user_facing_reason(reason)) + } + _ => None, + } +} + +fn external_tool_next_step(activation: &ExternalToolActivationState) -> &'static str { + match activation { + ExternalToolActivationState::ApprovalRequired => { + "Review access, then enable the target or keep it disabled." + } + ExternalToolActivationState::Disabled => { + "Enable the target after reviewing its source and access." + } + ExternalToolActivationState::Active => { + "No action is required. Disable the target to stop exposing it." + } + ExternalToolActivationState::Conflict => "Choose a provider below, or disable this target.", + ExternalToolActivationState::Unsupported { .. } => { + "Convert the module to the supported standalone JavaScript subset, then refresh." + } + ExternalToolActivationState::RuntimeUnavailable { .. } => { + "Restore the required runtime, then refresh." + } + ExternalToolActivationState::LoadFailed { .. } => { + "Refresh to retry. If it still fails, inspect or update the module, or disable this target." + } + _ => "Refresh to retrieve the current target state.", + } +} + +fn external_tool_default_reason(activation: &ExternalToolActivationState) -> &'static str { + match activation { + ExternalToolActivationState::ApprovalRequired => { + "The module needs approval before BitFun loads it." + } + ExternalToolActivationState::Disabled => "The target was disabled by user choice.", + ExternalToolActivationState::Active => { + "The module loaded successfully and is exposed in this execution domain." + } + ExternalToolActivationState::Conflict => "Another implementation uses the same tool name.", + ExternalToolActivationState::Unsupported { .. } => { + "The module uses unsupported syntax or behavior." + } + ExternalToolActivationState::RuntimeUnavailable { .. } => { + "The required JavaScript runtime is unavailable." + } + ExternalToolActivationState::LoadFailed { .. } => { + "The tool process could not load this module." + } + _ => "The current state is unavailable.", + } +} + +fn external_tool_can_enable(activation: &ExternalToolActivationState) -> bool { + matches!( + activation, + ExternalToolActivationState::ApprovalRequired | ExternalToolActivationState::Disabled + ) +} + +fn external_tool_can_disable(activation: &ExternalToolActivationState) -> bool { + matches!( + activation, + ExternalToolActivationState::ApprovalRequired + | ExternalToolActivationState::Active + | ExternalToolActivationState::Conflict + | ExternalToolActivationState::LoadFailed { .. } + ) +} + +fn external_tool_result_is_stale( + current: Option<&ExternalSourceCatalogSnapshot>, + incoming: &ExternalSourceCatalogSnapshot, +) -> bool { + current.is_some_and(|current| current.generation > incoming.generation) +} + +fn external_tool_pending_notice_key(snapshot: &ExternalSourceCatalogSnapshot) -> Option { + let mut decisions = snapshot + .tool_approval_requests + .iter() + .map(|request| format!("approval:{}", request.decision_key)) + .chain( + snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .map(|conflict| format!("conflict:{}", conflict.conflict_key)), + ) + .collect::>(); + decisions.extend(snapshot.diagnostics.iter().filter_map(|diagnostic| { + matches!( + diagnostic.severity, + ExternalSourceDiagnosticSeverity::Warning | ExternalSourceDiagnosticSeverity::Error + ) + .then(|| { + format!( + "diagnostic:{:?}:{}:{}:{}", + diagnostic.severity, + diagnostic.code, + diagnostic.message, + diagnostic + .source + .as_ref() + .map(|source| source.stable_key()) + .unwrap_or_default() + ) + }) + })); + if decisions.is_empty() { + return None; + } + decisions.sort_unstable(); + Some(decisions.join("\n")) +} + +fn external_tool_capability_label(capability: ExternalToolCapability) -> &'static str { + match capability { + ExternalToolCapability::FileSystem => "filesystem", + ExternalToolCapability::Network => "network", + ExternalToolCapability::Process => "process", + ExternalToolCapability::Environment => "environment variables", + _ => "other", + } +} + +fn external_tool_runtime_label(runtime: ExternalToolRuntimeKind) -> &'static str { + match runtime { + ExternalToolRuntimeKind::JavaScript => "JavaScript", + ExternalToolRuntimeKind::TypeScript => "TypeScript", + _ => "unknown runtime", + } +} + +fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { + let Some(snapshot) = snapshot else { + return "External tools\n\nTool discovery has not completed. Run /external-tools refresh and try again." + .to_string(); + }; + let mut lines = vec![ + "External tools".to_string(), + String::new(), + "No external code ran during discovery. Enabling a tool starts its external module with your user permissions and inherited environment variables; BitFun does not provide an OS sandbox or full descendant-process cleanup in this version." + .to_string(), + ]; + + if snapshot.discovery_pending { + lines.push(String::new()); + lines.push("Discovery is still running. Existing results remain usable.".to_string()); + } + + lines.push(String::new()); + lines.push("Targets".to_string()); + let targets = external_tool_target_summaries(snapshot); + if targets.is_empty() { + lines.push(" None".to_string()); + } else { + for (index, target) in targets.iter().enumerate() { + let tool = target.first(); + let source = snapshot + .sources + .iter() + .find(|source| source.record.key == tool.definition.id.target.source); + let capabilities = target + .tools + .iter() + .flat_map(|tool| tool.definition.capabilities.iter().copied()) + .collect::>() + .into_iter() + .map(external_tool_capability_label) + .collect::>() + .join(", "); + lines.push(format!( + " {}. {} - {}", + index + 1, + target.names(), + external_tool_activation_label(target.activation()) + )); + lines.push(format!( + " Source root: {}", + source + .map(|source| source.record.location.as_str()) + .unwrap_or("unknown") + )); + lines.push(" Module files:".to_string()); + let module_paths = target + .tools + .iter() + .map(|tool| tool.definition.module_path.as_str()) + .collect::>(); + for module_path in module_paths { + lines.push(format!(" - {module_path}")); + } + lines.push(format!( + " Scope: {}", + source + .map(|source| external_tool_scope_label(source.record.scope)) + .unwrap_or("unknown") + )); + lines.push(format!( + " Execution domain: {}", + source + .map(|source| source.record.execution_domain_id.as_str()) + .unwrap_or("unknown") + )); + lines.push(format!( + " Working directory: {}", + tool.definition.working_directory + )); + lines.push(format!( + " Runtime: {}", + external_tool_runtime_label(tool.definition.runtime_kind) + )); + lines.push(format!(" Access: {capabilities}")); + if let Some(reason) = external_tool_reason(target) { + lines.push(format!(" Reason: {reason}")); + } else { + lines.push(format!( + " Reason: {}", + external_tool_default_reason(target.activation()) + )); + } + lines.push(format!( + " Next step: {}", + external_tool_next_step(target.activation()) + )); + let mut commands = Vec::new(); + if external_tool_can_enable(target.activation()) { + commands.push(format!("/external-tools enable {}", index + 1)); + } + if external_tool_can_disable(target.activation()) { + commands.push(format!("/external-tools disable {}", index + 1)); + } + if !commands.is_empty() { + lines.push(format!(" Commands: {}", commands.join(" or "))); + } + } + } + + lines.push(String::new()); + lines.push("Name conflicts".to_string()); + let pending_conflicts = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .collect::>(); + if pending_conflicts.is_empty() { + lines.push(" None".to_string()); + } else { + for (conflict_index, conflict) in pending_conflicts.iter().enumerate() { + lines.push(format!( + " {}. Tool '{}' requires a provider choice:", + conflict_index + 1, + conflict.tool_name + )); + for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { + lines.push(format!( + " {}. {} ({}) - /external-tools choose {} {}", + candidate_index + 1, + candidate.display_name, + candidate.provider_id, + conflict_index + 1, + candidate_index + 1 + )); + } + lines.push( + " The choice applies to matching candidates in this execution domain." + .to_string(), + ); + } + } + + lines.push(String::new()); + lines.push("Diagnostics".to_string()); + if snapshot.diagnostics.is_empty() { + lines.push(" None".to_string()); + } else { + for diagnostic in &snapshot.diagnostics { + let severity = match diagnostic.severity { + ExternalSourceDiagnosticSeverity::Info => "info", + ExternalSourceDiagnosticSeverity::Warning => "warning", + ExternalSourceDiagnosticSeverity::Error => "error", + _ => "notice", + }; + let source = diagnostic + .source + .as_ref() + .map(|source| format!(" [{}]", source.stable_key())) + .unwrap_or_default(); + lines.push(format!( + " - {severity} [{}]{source}: {}", + diagnostic.code, + external_tool_user_facing_reason(&diagnostic.message) + )); + } + } + + lines.push(String::new()); + lines.push( + "Use /external-tools refresh after editing, upgrading, or removing external tools." + .to_string(), + ); + lines.join("\n") +} + +fn parse_positive_index(value: Option<&str>, label: &str) -> Result { + let raw = value.ok_or_else(|| format!("missing {label}"))?; + let index = raw + .parse::() + .map_err(|_| format!("{label} must be a positive number"))?; + if index == 0 { + return Err(format!("{label} must be a positive number")); + } + Ok(index - 1) +} + +fn parse_external_tool_review_action( + arguments: &str, + current_snapshot: Option<&ExternalSourceCatalogSnapshot>, + reviewed_snapshot: Option<&ExternalSourceCatalogSnapshot>, +) -> Result { + let mut parts = arguments.split_whitespace(); + let Some(command) = parts.next() else { + return Ok(ExternalToolReviewAction::Show); + }; + if command.eq_ignore_ascii_case("refresh") { + if parts.next().is_some() { + return Err("usage: /external-tools refresh".to_string()); + } + return Ok(ExternalToolReviewAction::Refresh); + } + if command.eq_ignore_ascii_case("help") { + return Ok(ExternalToolReviewAction::Show); + } + // Numbered commands refer to the immutable catalog that produced the + // review popup. The backend still validates stable decision/conflict keys, + // so a changed target fails closed instead of reusing the same number for + // a different tool after a watcher refresh. + let snapshot = reviewed_snapshot.or(current_snapshot).ok_or_else(|| { + "tool discovery has not completed; run /external-tools refresh".to_string() + })?; + if command.eq_ignore_ascii_case("enable") || command.eq_ignore_ascii_case("disable") { + let index = parse_positive_index(parts.next(), "target number")?; + if parts.next().is_some() { + return Err(format!("usage: /external-tools {command} ")); + } + let targets = external_tool_target_summaries(snapshot); + let target = targets.get(index).ok_or_else(|| { + "that target is no longer available; reopen /external-tools".to_string() + })?; + let approved = command.eq_ignore_ascii_case("enable"); + let allowed = if approved { + external_tool_can_enable(target.activation()) + } else { + external_tool_can_disable(target.activation()) + }; + if !allowed { + return Err(format!( + "target {} is {}; reopen /external-tools for its next step", + index + 1, + external_tool_activation_label(target.activation()) + )); + } + let tool = target.first(); + return Ok(ExternalToolReviewAction::Decide { + approval_key: tool.approval_key.clone(), + decision_key: tool.decision_key.clone(), + approved, + }); + } + if command.eq_ignore_ascii_case("choose") { + let conflict_index = parse_positive_index(parts.next(), "conflict number")?; + let candidate_index = parse_positive_index(parts.next(), "candidate number")?; + if parts.next().is_some() { + return Err( + "usage: /external-tools choose ".to_string(), + ); + } + let conflict = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .nth(conflict_index) + .ok_or_else(|| { + "that conflict is no longer available; reopen /external-tools".to_string() + })?; + let candidate = conflict.candidates.get(candidate_index).ok_or_else(|| { + "that candidate is no longer available; reopen /external-tools".to_string() + })?; + return Ok(ExternalToolReviewAction::Choose { + conflict_key: conflict.conflict_key.clone(), + candidate_id: candidate.candidate_id.clone(), + }); + } + Err("usage: /external-tools [refresh | enable | disable | choose ]".to_string()) +} + +fn external_tool_mutation_result_label( + action: &ExternalToolReviewAction, + snapshot: &ExternalSourceCatalogSnapshot, +) -> String { + match action { + ExternalToolReviewAction::Refresh => "External tools refreshed".to_string(), + ExternalToolReviewAction::Decide { + approval_key, + decision_key, + approved: true, + } => { + let activations = snapshot + .tools + .iter() + .filter(|tool| { + tool.approval_key == *approval_key && tool.decision_key == *decision_key + }) + .map(|tool| &tool.activation) + .collect::>(); + if activations.is_empty() { + "External tool approval saved; reopen /external-tools to review the changed target" + .to_string() + } else if activations + .iter() + .any(|state| matches!(state, ExternalToolActivationState::LoadFailed { .. })) + { + "External tool approved, but loading failed".to_string() + } else if activations.iter().any(|state| { + matches!( + state, + ExternalToolActivationState::RuntimeUnavailable { .. } + ) + }) { + "External tool approved, but its runtime is unavailable".to_string() + } else if activations + .iter() + .any(|state| matches!(state, ExternalToolActivationState::Conflict)) + { + "External tool approved; choose a provider before every export is available" + .to_string() + } else if activations + .iter() + .all(|state| matches!(state, ExternalToolActivationState::Active)) + { + "External tool enabled".to_string() + } else { + "External tool approval saved; reopen /external-tools to review its current state" + .to_string() + } + } + ExternalToolReviewAction::Decide { + approval_key, + decision_key, + approved: false, + } => { + let disabled = snapshot.tools.iter().any(|tool| { + tool.approval_key == *approval_key + && tool.decision_key == *decision_key + && matches!(tool.activation, ExternalToolActivationState::Disabled) + }); + if disabled { + "External tool disabled".to_string() + } else { + "External tool decision saved; reopen /external-tools to review the changed target" + .to_string() + } + } + ExternalToolReviewAction::Choose { + conflict_key, + candidate_id, + } => { + let selected = snapshot.tool_conflicts.iter().any(|conflict| { + conflict.conflict_key == *conflict_key + && conflict.selected_candidate_id.as_deref() == Some(candidate_id.as_str()) + }); + if selected { + "External tool provider selected".to_string() + } else { + "External tool candidates changed; reopen /external-tools before choosing" + .to_string() + } + } + ExternalToolReviewAction::Show => "External tools".to_string(), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct BuiltinCommandReconfirmation { conflict_key: String, @@ -472,6 +1059,9 @@ pub(crate) struct ChatMode { external_source_conflict_choices: BTreeMap, external_source_conflict_lineage_current_keys: BTreeMap, external_source_conflicted_candidate_ids: BTreeSet, + external_tool_notice_key: Option, + external_tool_review_snapshot: Option, + external_tool_mutation_rx: Option>, } /// Map agent_type to a display name for status messages @@ -510,6 +1100,9 @@ impl ChatMode { external_source_conflict_choices: BTreeMap::new(), external_source_conflict_lineage_current_keys: BTreeMap::new(), external_source_conflicted_candidate_ids: BTreeSet::new(), + external_tool_notice_key: None, + external_tool_review_snapshot: None, + external_tool_mutation_rx: None, } } @@ -546,6 +1139,182 @@ impl ChatMode { ); } + fn take_external_tool_notice( + &mut self, + snapshot: &ExternalSourceCatalogSnapshot, + ) -> Option { + let next_key = external_tool_pending_notice_key(snapshot); + if next_key == self.external_tool_notice_key { + return None; + } + self.external_tool_notice_key = next_key; + let approvals = snapshot.tool_approval_requests.len(); + let conflicts = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let diagnostics = snapshot + .diagnostics + .iter() + .filter(|diagnostic| { + matches!( + diagnostic.severity, + ExternalSourceDiagnosticSeverity::Warning + | ExternalSourceDiagnosticSeverity::Error + ) + }) + .count(); + if approvals + conflicts + diagnostics == 0 { + None + } else { + Some(format!( + "External tools need attention: {approvals} approvals, {conflicts} name conflicts, {diagnostics} diagnostics - run /external-tools" + )) + } + } + + fn handle_external_tool_review( + &mut self, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let action = match parse_external_tool_review_action( + arguments, + self.external_source_snapshot.as_ref(), + self.external_tool_review_snapshot.as_ref(), + ) { + Ok(action) => action, + Err(error) => { + chat_view.set_status(Some(error)); + return; + } + }; + if matches!(action, ExternalToolReviewAction::Show) { + self.external_tool_review_snapshot = self.external_source_snapshot.clone(); + chat_view.show_info_popup(external_tool_review_text( + self.external_tool_review_snapshot.as_ref(), + )); + return; + } + + if self.external_tool_mutation_rx.is_some() { + chat_view.set_status(Some( + "An external tool update is already running; input and cancellation remain available." + .to_string(), + )); + return; + } + + let workspace = self.workspace_path_for_sync(chat_state); + let pending_status = match &action { + ExternalToolReviewAction::Refresh => "Refreshing external tools", + ExternalToolReviewAction::Decide { approved: true, .. } => "Enabling external tool", + ExternalToolReviewAction::Decide { + approved: false, .. + } => "Disabling external tool", + ExternalToolReviewAction::Choose { .. } => "Selecting external tool provider", + ExternalToolReviewAction::Show => unreachable!(), + }; + let task_action = action.clone(); + let (sender, receiver) = mpsc::channel(); + rt_handle.spawn(async move { + let result = match &task_action { + ExternalToolReviewAction::Refresh => { + external_source_snapshot(Some(&workspace), true).await + } + ExternalToolReviewAction::Decide { + approval_key, + decision_key, + approved, + } => { + set_external_tool_target_decision( + Some(&workspace), + approval_key, + decision_key, + *approved, + ) + .await + } + ExternalToolReviewAction::Choose { + conflict_key, + candidate_id, + } => { + set_external_tool_conflict_choice(Some(&workspace), conflict_key, candidate_id) + .await + } + ExternalToolReviewAction::Show => unreachable!(), + } + .map_err(|error| error.to_string()); + let _ = sender.send(ExternalToolMutationResult { + action: task_action, + result, + }); + }); + self.external_tool_mutation_rx = Some(receiver); + chat_view.set_status(Some(format!( + "{pending_status}; you can continue typing or cancel other UI work" + ))); + } + + fn poll_external_tool_mutation(&mut self, chat_view: &mut ChatView) -> bool { + let outcome = match self + .external_tool_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_tool_mutation_rx = None; + chat_view.set_status(Some( + "External tool update stopped before returning a result; reopen /external-tools and retry." + .to_string(), + )); + return true; + } + }; + self.external_tool_mutation_rx = None; + match outcome.result { + Ok(snapshot) => { + if external_tool_result_is_stale(self.external_source_snapshot.as_ref(), &snapshot) + { + chat_view.set_status(Some( + "External tool update completed; a newer catalog result is already displayed." + .to_string(), + )); + return true; + } + self.update_external_source_view(chat_view, &snapshot); + self.external_tool_notice_key = external_tool_pending_notice_key(&snapshot); + let approvals = snapshot.tool_approval_requests.len(); + let conflicts = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let result_label = external_tool_mutation_result_label(&outcome.action, &snapshot); + self.external_source_snapshot = Some(snapshot); + if approvals + conflicts == 0 { + chat_view.set_status(Some(result_label)); + } else { + chat_view.set_status(Some(format!( + "{result_label}; {approvals} approvals and {conflicts} conflicts remain - run /external-tools" + ))); + } + } + Err(error) => { + tracing::warn!("External tool review action failed: {}", error); + chat_view.set_status(Some(format!( + "External tool choice was not applied: {error}. Reopen /external-tools to review current results." + ))); + } + } + true + } + fn replace_external_conflict_preferences( &mut self, preferences: ExternalSourceConflictPreferences, @@ -913,12 +1682,15 @@ impl ChatMode { .iter() .filter(|conflict| conflict.selected_candidate_id.is_none()) .count(); + let tool_notice = self.take_external_tool_notice(&snapshot); self.update_external_source_view(&mut chat_view, &snapshot); self.external_source_snapshot = Some(snapshot.clone()); if snapshot.discovery_pending { chat_view.set_status(Some( - "Checking compatible commands from external AI applications".to_string(), + "Checking compatible content from external AI applications".to_string(), )); + } else if let Some(notice) = tool_notice { + chat_view.set_status(Some(notice)); } else if available + restricted > 0 || pending_conflicts > 0 { chat_view.set_status(Some(format!( "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" @@ -1017,6 +1789,9 @@ impl ChatMode { if self.poll_mcp_task_completion(&mut chat_view, &mut chat_state, &rt_handle) { needs_redraw = true; } + if self.poll_external_tool_mutation(&mut chat_view) { + needs_redraw = true; + } let mut external_source_closed = false; if let Some(receiver) = external_source_rx.as_mut() { @@ -1046,12 +1821,14 @@ impl ChatMode { if let Ok(preferences) = preferences { self.replace_external_conflict_preferences(preferences); } + let tool_notice = self.take_external_tool_notice(&snapshot); self.update_external_source_view(&mut chat_view, &snapshot); if snapshot.discovery_pending { chat_view.set_status(Some( - "Checking compatible commands from external AI applications" - .to_string(), + "Checking compatible content from external AI applications".to_string(), )); + } else if let Some(notice) = tool_notice { + chat_view.set_status(Some(notice)); } else if discovery_just_finished { let (available, restricted) = external_command_counts(&snapshot); let pending_conflicts = snapshot @@ -1639,7 +2416,16 @@ impl ChatMode { // Info popup intercepts all keys when visible if chat_view.info_popup_visible() { - chat_view.dismiss_info_popup(); + match key.code { + KeyCode::Up => chat_view.info_popup_scroll_up(1), + KeyCode::Down => chat_view.info_popup_scroll_down(1), + KeyCode::PageUp => chat_view.info_popup_scroll_up(10), + KeyCode::PageDown => chat_view.info_popup_scroll_down(10), + KeyCode::Home => chat_view.info_popup_scroll_to_start(), + KeyCode::End => chat_view.info_popup_scroll_to_end(), + KeyCode::Esc => chat_view.dismiss_info_popup(), + _ => {} + } return Ok(None); } @@ -2268,7 +3054,22 @@ impl ChatMode { ); } } + let builtin_reconfirmation_required = external.is_none() + && builtin_reconfirmation + .as_ref() + .is_some_and(|reconfirmation| !reconfirmation.confirmed); let unresolved_candidates = self.external_conflict_projections(command_name); + let can_route_external_tool_review = builtin_action + .is_some_and(|action| action.handler == ActionHandler::ExternalTools) + && qualifier != CommandQualifier::External + && (qualifier == CommandQualifier::Builtin + || (external.is_none() + && unresolved_candidates.is_empty() + && !builtin_reconfirmation_required)); + if can_route_external_tool_review { + self.handle_external_tool_review(arguments, chat_view, chat_state, rt_handle); + return Ok(None); + } let native_choice_is_active = unresolved_candidates.iter().any(|candidate| { candidate .native_collision @@ -2306,10 +3107,6 @@ impl ChatMode { .external_source_snapshot .as_ref() .is_some_and(|snapshot| snapshot.discovery_pending); - let builtin_reconfirmation_required = external.is_none() - && builtin_reconfirmation - .as_ref() - .is_some_and(|reconfirmation| !reconfirmation.confirmed); match command_route( qualifier, builtin_action.is_some(), @@ -2688,6 +3485,9 @@ impl ChatMode { ActionHandler::McpServers => { self.show_mcp_selector(chat_view, chat_state, rt_handle); } + ActionHandler::ExternalTools => { + self.handle_external_tool_review("", chat_view, chat_state, rt_handle); + } ActionHandler::AcpHelp => { chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun-cli")); chat_view.set_status(Some( @@ -4691,14 +5491,19 @@ mod tests { use super::{ agent_event_stream_failure, builtin_command_reconfirmation, command_route, - external_command_projections, mark_active_turn_failed, parse_command_token, + external_command_projections, external_tool_mutation_result_label, + external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, + mark_active_turn_failed, parse_command_token, parse_external_tool_review_action, CommandQualifier, CommandRoute, ExternalSourceConflictPreferences, + ExternalToolReviewAction, }; use crate::actions::{ActionState, ResolvedKeymap}; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; - use bitfun_core::external_sources::ExternalSourceCatalogSnapshot; + use bitfun_core::external_sources::{ + ExternalSourceCatalogSnapshot, ExternalToolActivationState, + }; use std::collections::{BTreeMap, BTreeSet}; fn external_command( @@ -4724,6 +5529,291 @@ mod tests { } } + fn external_tool_review_snapshot() -> ExternalSourceCatalogSnapshot { + serde_json::from_value(serde_json::json!({ + "generation": 3, + "discoveryPending": false, + "sources": [{ + "stableKey": "opencode-tools-project", + "record": { + "key": { "providerId": "opencode.tools", "sourceId": "project" }, + "ecosystemId": "opencode", + "displayName": "OpenCode project tools", + "sourceKind": "tools", + "scope": "project", + "location": "D:/repo/.opencode/tools", + "executionDomainId": "local:D:/repo", + "health": "available", + "contentVersion": "source-v1" + }, + "lifecycle": "available" + }], + "commands": [], + "tools": [{ + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "review.js" + }, + "exportId": "default" + }, + "name": "review", + "descriptionPreview": "Review a change", + "modulePath": "D:/repo/.opencode/tools/review.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["file_system", "network", "environment", "process"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v1", + "decisionKey": "decision-v1", + "activation": { "state": "approval_required" } + }, { + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "weather.js" + }, + "exportId": "default" + }, + "name": "weather", + "descriptionPreview": "Read weather", + "modulePath": "D:/repo/.opencode/tools/weather.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["network"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v2", + "decisionKey": "decision-v2", + "activation": { "state": "disabled" } + }, { + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "deploy.js" + }, + "exportId": "default" + }, + "name": "deploy", + "descriptionPreview": "Deploy a build", + "modulePath": "D:/repo/.opencode/tools/deploy.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["process"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v3", + "decisionKey": "decision-v3", + "activation": { "state": "active" } + }, { + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "broken.ts" + }, + "exportId": "default" + }, + "name": "broken", + "descriptionPreview": "Broken tool", + "modulePath": "D:/repo/.opencode/tools/broken.ts", + "workingDirectory": "D:/repo", + "runtimeKind": "type_script", + "capabilities": ["file_system"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v4", + "decisionKey": "decision-v4", + "activation": { + "state": "load_failed", + "reason": "PR2 worker could not import the module" + } + }], + "toolApprovalRequests": [{ + "approvalKey": "approval-v1", + "decisionKey": "decision-v1", + "targetId": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "review.js" + }, + "sourceDisplayName": "OpenCode project tools", + "sourceScope": "project", + "sourceLocation": "D:/repo/.opencode/tools/review.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["file_system", "network", "environment", "process"], + "contentVersion": "content-v1", + "toolNames": ["review"] + }], + "toolConflicts": [{ + "conflictKey": "conflict-v1", + "toolName": "review", + "candidates": [{ + "candidateId": "bitfun:review", + "displayName": "BitFun review", + "kind": "built_in", + "providerId": "bitfun", + "contentVersion": "builtin-v1" + }, { + "candidateId": "external:review", + "displayName": "OpenCode review", + "kind": "external", + "providerId": "opencode.tools", + "contentVersion": "content-v1", + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "sourceLocation": "D:/repo/.opencode/tools/review.js" + }] + }], + "diagnostics": [{ + "severity": "warning", + "code": "opencode.tool.directory_read_failed", + "message": "PR2 worker could not read one tool directory", + "source": { "providerId": "opencode.tools", "sourceId": "project" } + }] + })) + .unwrap() + } + + #[test] + fn external_tool_review_summary_discloses_execution_boundary_and_commands() { + let summary = external_tool_review_text(Some(&external_tool_review_snapshot())); + + assert!(summary.contains("No external code ran during discovery")); + assert!(summary.contains("filesystem, network, process, environment variables")); + assert!(summary.contains("inherited environment variables")); + assert!(summary.contains("full descendant-process cleanup")); + assert!(summary.contains("/external-tools enable 1")); + assert!(summary.contains("/external-tools choose 1 2")); + assert!(summary.contains("D:/repo/.opencode/tools/review.js")); + assert!(summary.contains("Source root: D:/repo/.opencode/tools")); + assert!(summary.contains("Scope: project")); + assert!(summary.contains("Execution domain: local:D:/repo")); + assert!(summary.contains("disabled")); + assert!(summary.contains("active")); + assert!(summary.contains("loaded successfully")); + assert!(summary.contains("load failed")); + assert!(summary.contains("D:/repo/.opencode/tools/broken.ts")); + assert!(summary.contains("Diagnostics")); + assert!(summary.contains("opencode.tool.directory_read_failed")); + assert!(!summary.contains("PR2")); + } + + #[test] + fn external_tool_review_commands_resolve_indices_to_stable_keys() { + let snapshot = external_tool_review_snapshot(); + + assert_eq!( + parse_external_tool_review_action("enable 2", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v2".to_string(), + decision_key: "decision-v2".to_string(), + approved: true, + } + ); + assert_eq!( + parse_external_tool_review_action("disable 3", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v3".to_string(), + decision_key: "decision-v3".to_string(), + approved: false, + } + ); + assert_eq!( + parse_external_tool_review_action("disable 4", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v4".to_string(), + decision_key: "decision-v4".to_string(), + approved: false, + } + ); + assert_eq!( + parse_external_tool_review_action("choose 1 2", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Choose { + conflict_key: "conflict-v1".to_string(), + candidate_id: "external:review".to_string(), + } + ); + assert!(parse_external_tool_review_action("enable 3", Some(&snapshot), None).is_err()); + } + + #[test] + fn external_tool_review_commands_keep_the_indices_from_the_displayed_review() { + let reviewed = external_tool_review_snapshot(); + let mut current = reviewed.clone(); + current.tools.swap(0, 1); + + assert_eq!( + parse_external_tool_review_action("enable 2", Some(¤t), Some(&reviewed)).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v2".to_string(), + decision_key: "decision-v2".to_string(), + approved: true, + } + ); + } + + #[test] + fn external_tool_enable_result_reports_the_returned_activation() { + let mut snapshot = external_tool_review_snapshot(); + snapshot.tools[0].activation = ExternalToolActivationState::LoadFailed { + reason: "module import failed".to_string(), + }; + let action = ExternalToolReviewAction::Decide { + approval_key: "approval-v1".to_string(), + decision_key: "decision-v1".to_string(), + approved: true, + }; + + assert_eq!( + external_tool_mutation_result_label(&action, &snapshot), + "External tool approved, but loading failed" + ); + } + + #[test] + fn external_tool_notice_key_changes_for_pending_decisions_or_diagnostics() { + let snapshot = external_tool_review_snapshot(); + let key = external_tool_pending_notice_key(&snapshot).unwrap(); + let mut generation_only = snapshot.clone(); + generation_only.generation += 1; + assert_eq!( + external_tool_pending_notice_key(&generation_only), + Some(key.clone()) + ); + + generation_only.tool_approval_requests[0].decision_key = "decision-v2".to_string(); + assert_ne!( + external_tool_pending_notice_key(&generation_only), + Some(key.clone()) + ); + + let mut diagnostic_change = snapshot; + diagnostic_change.diagnostics[0].message = "different failure".to_string(); + assert_ne!( + external_tool_pending_notice_key(&diagnostic_change), + Some(key) + ); + } + + #[test] + fn external_tool_mutation_result_does_not_overwrite_a_newer_catalog_generation() { + let incoming = external_tool_review_snapshot(); + let mut current = incoming.clone(); + current.generation += 1; + + assert!(external_tool_result_is_stale(Some(¤t), &incoming)); + assert!(!external_tool_result_is_stale(Some(&incoming), ¤t)); + assert!(!external_tool_result_is_stale(None, &incoming)); + } + #[test] fn explicit_builtin_never_falls_through_to_an_external_command() { let external = external_command("review", None); diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 03d595db75..8e20b4d40b 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -3,6 +3,8 @@ impl ChatView { pub(crate) fn show_info_popup(&mut self, message: String) { self.info_popup = Some(message); + self.info_popup_scroll = 0; + self.info_popup_max_scroll = 0; self.popup_stack.push(PopupType::InfoPopup); } @@ -12,6 +14,27 @@ impl ChatView { pub(crate) fn dismiss_info_popup(&mut self) { self.info_popup = None; + self.info_popup_scroll = 0; + self.info_popup_max_scroll = 0; + } + + pub(crate) fn info_popup_scroll_up(&mut self, amount: u16) { + self.info_popup_scroll = self.info_popup_scroll.saturating_sub(amount); + } + + pub(crate) fn info_popup_scroll_down(&mut self, amount: u16) { + self.info_popup_scroll = self + .info_popup_scroll + .saturating_add(amount) + .min(self.info_popup_max_scroll); + } + + pub(crate) fn info_popup_scroll_to_start(&mut self) { + self.info_popup_scroll = 0; + } + + pub(crate) fn info_popup_scroll_to_end(&mut self) { + self.info_popup_scroll = self.info_popup_max_scroll; } // ============ Command palette methods ============ diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index dad28c5197..08f98d4d7e 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -123,7 +123,15 @@ impl ChatView { // Info popup overlay (topmost) if let Some(ref msg) = self.info_popup { - super::widgets::render_info_popup(frame, size, msg, self.theme.primary); + let (scroll, max_scroll) = super::widgets::render_info_popup_scrolled( + frame, + size, + msg, + self.theme.primary, + self.info_popup_scroll, + ); + self.info_popup_scroll = scroll; + self.info_popup_max_scroll = max_scroll; } } diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 0973be34df..d572952a42 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -191,8 +191,10 @@ pub(crate) struct ChatView { /// Pending subagent selector action from mouse click (consumed by caller) pending_subagent_action: Option, - /// Info popup message (rendered as overlay, dismissed by any key) + /// Info popup message and vertical scroll position. info_popup: Option, + info_popup_scroll: u16, + info_popup_max_scroll: u16, /// Hovered thinking block (message_id) for mouse-over highlight hovered_thinking_block_id: Option, @@ -271,6 +273,8 @@ impl ChatView { pending_theme_preview: None, theme_preview_original: None, info_popup: None, + info_popup_scroll: 0, + info_popup_max_scroll: 0, hovered_thinking_block_id: None, collapsed_tools: HashSet::new(), focused_block_tool: None, diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index e029047e68..80c4888085 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] = &[ "theme", "switch_agent", "mcp_servers", + "external_tools", "login", "logout", "help", diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index f69efd3afb..e5bc979ba6 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1103,6 +1103,7 @@ impl StartupPage { ActionHandler::NavigateBack => self.navigate_back(), ActionHandler::ClearConversation | ActionHandler::ReloadSkills + | ActionHandler::ExternalTools | ActionHandler::History | ActionHandler::Interrupt | ActionHandler::ToggleFocusedTool diff --git a/src/apps/cli/src/ui/widgets.rs b/src/apps/cli/src/ui/widgets.rs index afcef2025b..2a9c2aba64 100644 --- a/src/apps/cli/src/ui/widgets.rs +++ b/src/apps/cli/src/ui/widgets.rs @@ -8,7 +8,84 @@ use ratatui::{ }; use unicode_width::UnicodeWidthStr; -const INFO_POPUP_DISMISS_HINT: &str = " Press Esc to dismiss "; +const INFO_POPUP_DISMISS_HINT: &str = " Up/Down or PgUp/PgDn to scroll - Esc to dismiss "; +const INFO_POPUP_NARROW_DISMISS_HINT: &str = " Esc close "; + +fn info_popup_dismiss_hint(width: u16) -> &'static str { + if INFO_POPUP_DISMISS_HINT.width() <= usize::from(width) { + INFO_POPUP_DISMISS_HINT + } else { + INFO_POPUP_NARROW_DISMISS_HINT + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct InfoPopupLayout { + area: Rect, + scroll: u16, + max_scroll: u16, +} + +fn wrapped_line_count(message: &str, width: u16) -> u16 { + let width = usize::from(width.max(1)); + message + .lines() + .map(|line| { + let line_width = line.width().max(1); + line_width.div_ceil(width).min(usize::from(u16::MAX)) as u16 + }) + .fold(0_u16, u16::saturating_add) + .max(1) +} + +fn info_popup_layout(area: Rect, message: &str, requested_scroll: u16) -> InfoPopupLayout { + if area.width == 0 || area.height == 0 { + return InfoPopupLayout { + area, + scroll: 0, + max_scroll: 0, + }; + } + + let horizontal_margin: u16 = if area.width >= 8 { 2 } else { 0 }; + let max_width = area + .width + .saturating_sub(horizontal_margin.saturating_mul(2)) + .max(1); + let max_line_width = message + .lines() + .map(|line| line.width().min(usize::from(u16::MAX)) as u16) + .max() + .unwrap_or(20); + let minimum_width = 30.min(max_width); + let popup_width = max_line_width + .saturating_add(4) + .clamp(minimum_width, max_width); + + let hint_height = u16::from(area.height >= 4); + let max_height = area.height.saturating_sub(hint_height).max(1); + let unwrapped_height = message.lines().count().min(usize::from(u16::MAX)) as u16; + let minimum_height = 3.min(max_height); + let popup_height = unwrapped_height + .saturating_add(2) + .clamp(minimum_height, max_height); + let content_width = popup_width.saturating_sub(2).max(1); + let visible_lines = popup_height.saturating_sub(2).max(1); + let max_scroll = wrapped_line_count(message, content_width).saturating_sub(visible_lines); + let scroll = requested_scroll.min(max_scroll); + + let popup_x = area.x + area.width.saturating_sub(popup_width) / 2; + let popup_y = area.y + + area + .height + .saturating_sub(popup_height.saturating_add(hint_height)) + / 2; + InfoPopupLayout { + area: Rect::new(popup_x, popup_y, popup_width, popup_height), + scroll, + max_scroll, + } +} pub(super) struct Spinner { frame: usize, @@ -32,6 +109,16 @@ impl Spinner { /// Render a centered info popup overlay. Esc always dismisses it. pub(super) fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, accent: Color) { + let _ = render_info_popup_scrolled(frame, area, message, accent, 0); +} + +pub(super) fn render_info_popup_scrolled( + frame: &mut Frame, + area: Rect, + message: &str, + accent: Color, + requested_scroll: u16, +) -> (u16, u16) { let lines: Vec = message .lines() .map(|l| { @@ -42,28 +129,8 @@ pub(super) fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, ac }) .collect(); - let line_count = lines.len() as u16; - let max_line_width = message - .lines() - .map(|l| l.width() as u16) - .max() - .unwrap_or(20); - - // +2 for border, +2 for padding; +1 for hint line below popup - let popup_width = (max_line_width + 4) - .min(area.width.saturating_sub(4)) - .max(30); - let popup_height = (line_count + 2).min(area.height.saturating_sub(3)); - - let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_height + 1)) / 2; - - let popup_area = Rect { - x: popup_x, - y: popup_y, - width: popup_width, - height: popup_height, - }; + let layout = info_popup_layout(area, message, requested_scroll); + let popup_area = layout.area; let block = Block::default() .borders(Borders::ALL) @@ -73,7 +140,8 @@ pub(super) fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, ac let text = Paragraph::new(lines) .block(block) - .wrap(Wrap { trim: false }); + .wrap(Wrap { trim: false }) + .scroll((layout.scroll, 0)); frame.render_widget(Clear, popup_area); frame.render_widget(text, popup_area); @@ -88,20 +156,40 @@ pub(super) fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, ac height: 1, }; let hint = Paragraph::new(Line::from(Span::styled( - INFO_POPUP_DISMISS_HINT, + info_popup_dismiss_hint(hint_area.width), Style::default().fg(Color::DarkGray), ))); frame.render_widget(hint, hint_area); } + (layout.scroll, layout.max_scroll) } #[cfg(test)] mod tests { - use super::INFO_POPUP_DISMISS_HINT; + use ratatui::layout::Rect; + use unicode_width::UnicodeWidthStr; + + use super::{info_popup_dismiss_hint, info_popup_layout, INFO_POPUP_DISMISS_HINT}; #[test] fn info_popup_names_the_modal_safe_dismiss_key() { assert!(INFO_POPUP_DISMISS_HINT.contains("Esc")); assert!(!INFO_POPUP_DISMISS_HINT.contains("any key")); } + + #[test] + fn info_popup_layout_stays_inside_a_narrow_viewport() { + let viewport = Rect::new(7, 11, 18, 8); + let layout = info_popup_layout(viewport, "a very long line that must wrap", u16::MAX); + + assert!(layout.area.x >= viewport.x); + assert!(layout.area.y >= viewport.y); + assert!(layout.area.right() <= viewport.right()); + assert!(layout.area.bottom() <= viewport.bottom()); + assert!(layout.max_scroll > 0); + assert_eq!(layout.scroll, layout.max_scroll); + let hint = info_popup_dismiss_hint(layout.area.width); + assert!(hint.contains("Esc")); + assert!(hint.width() <= usize::from(layout.area.width)); + } } diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index e846442c4e..514fde9c55 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -2,8 +2,10 @@ use bitfun_core::external_sources::{ external_source_snapshot, set_external_prompt_command_conflict_choice, - set_external_source_enabled, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, - ExternalSourceDiagnostic, PromptCommandAvailability, + set_external_source_enabled, set_external_tool_conflict_choice, + set_external_tool_target_decision, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, + ExternalSourceDiagnostic, ExternalToolApprovalRequest, ExternalToolCatalogEntry, + ExternalToolConflict, PromptCommandAvailability, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_product_domains::external_sources::{PromptCommandConflict, SourceQualifiedCommandId}; @@ -34,6 +36,23 @@ pub struct SetExternalSourceConflictChoiceRequest { pub candidate_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalToolTargetDecisionRequest { + pub workspace_path: Option, + pub approval_key: String, + pub decision_key: String, + pub approved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalToolConflictChoiceRequest { + pub workspace_path: Option, + pub conflict_key: String, + pub candidate_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceSnapshotResponse { @@ -44,6 +63,12 @@ pub struct ExternalSourceSnapshotResponse { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub command_conflicts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_approval_requests: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } @@ -83,6 +108,9 @@ impl From for ExternalSourceSnapshotResponse { }) .collect(), command_conflicts: snapshot.command_conflicts, + tools: snapshot.tools, + tool_approval_requests: snapshot.tool_approval_requests, + tool_conflicts: snapshot.tool_conflicts, diagnostics: snapshot.diagnostics, } } @@ -138,6 +166,31 @@ pub async fn set_external_source_conflict_choice_command( .map(Into::into) } +#[tauri::command] +pub async fn set_external_tool_target_decision_command( + request: SetExternalToolTargetDecisionRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_tool_target_decision( + workspace, + &request.approval_key, + &request.decision_key, + request.approved, + ) + .await + .map(Into::into) +} + +#[tauri::command] +pub async fn set_external_tool_conflict_choice_command( + request: SetExternalToolConflictChoiceRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_tool_conflict_choice(workspace, &request.conflict_key, &request.candidate_id) + .await + .map(Into::into) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 8b49ccba6e..feec6dc002 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1361,6 +1361,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_source_enabled_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_tool_conflict_choice_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "set_external_tool_target_decision_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly), ( "set_miniapp_draft_storage", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 749d16d25a..d47daafd54 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -913,6 +913,8 @@ pub async fn run() { get_external_source_snapshot, set_external_source_enabled_command, set_external_source_conflict_choice_command, + set_external_tool_target_decision_command, + set_external_tool_conflict_choice_command, api::context_upload_api::upload_image_contexts, get_all_tools_info, get_readonly_tools_info, diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index aa07f18f50..e3e722a59c 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -2,12 +2,13 @@ # OpenCode Adapter -The current crate owns the P0 static OpenCode source preview used by the existing -managed-package path and the OpenCode-specific implementations of capability -provider contracts. It preserves OpenCode source discovery, precedence, formats, -argument expansion, and versioned compatibility semantics. Shared source catalog, -lifecycle coordination, file-watch implementation, product policy, UI, credentials, -worker supervision, and final effect writes belong elsewhere. +The current crate owns the static OpenCode source preview used by the existing +managed-package path and the OpenCode-specific implementations of command and +standalone-tool provider contracts. It preserves OpenCode source discovery, +precedence, formats, argument expansion, and versioned compatibility semantics. +Shared source catalog, lifecycle coordination, file-watch implementation, +product policy, UI, credentials, worker supervision, and final effect writes +belong elsewhere. Product-source boundary: @@ -44,6 +45,10 @@ Product-source boundary: After activation, the default local runtime policy is compatibility mode. - Final tool creation, permission decisions, authoritative state, and audit facts stay in their tool, permission, product, and runtime owner paths. +- Standalone-tool preparation may return only a version-checked, bounded module + for an already approved target. It must not spawn a process, install a package, + persist approval, or interpret another ecosystem. Static import restrictions + describe the current compatibility subset; they are not a security sandbox. - The user's local `opencode` CLI installation is unrelated to loading OpenCode-compatible plugins. CLI/server interop with an installed OpenCode binary belongs to ACP/external-client work, not this adapter boundary. diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index 4be76092be..5d387d73cd 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -24,9 +24,10 @@ serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } urlencoding = { workspace = true } +url = { workspace = true } [dev-dependencies] -bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["plugin-source"] } +bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["plugin-source", "script-tool-runtime"] } tokio = { workspace = true } tempfile = { workspace = true } diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index a42e67f7c7..a39ab43a54 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -7,6 +7,8 @@ mod command_source; mod source_adapter; +mod tool_source; pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; pub use source_adapter::load_opencode_package_adapter; +pub use tool_source::{OpenCodeToolProvider, OpenCodeToolProviderOptions}; diff --git a/src/crates/adapters/opencode-adapter/src/tool_source.rs b/src/crates/adapters/opencode-adapter/src/tool_source.rs new file mode 100644 index 0000000000..1af66d6199 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/tool_source.rs @@ -0,0 +1,895 @@ +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, + ExternalToolDefinition, ExternalToolProviderIdentity, ExternalToolProviderSnapshot, + ExternalToolRuntimeKind, ExternalToolSourceProvider, ExternalToolStaticStatus, + ExternalWatchRoot, PreparedExternalToolExport, PreparedExternalToolTarget, SourceKey, + SourceQualifiedToolId, SourceQualifiedToolTargetId, +}; +use regex::Regex; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +#[cfg(test)] +use std::sync::Arc; + +const PROVIDER_ID: &str = "opencode.tools"; +const ECOSYSTEM_ID: &str = "opencode"; +const MAX_TOOL_FILES: usize = 1024; +const MAX_TOOL_FILE_BYTES: u64 = 512 * 1024; + +const TOOL_SHIM: &str = r#" +const __bitfunSchema = (schema) => Object.assign(schema, { + describe(description) { this.description = description; return this; }, + optional() { this.__optional = true; return this; }, + default(value) { this.__default = value; this.__optional = true; return this; }, + min(value) { + if (this.type === "string") this.minLength = value; + else if (this.type === "array") this.minItems = value; + else this.minimum = value; + return this; + }, + max(value) { + if (this.type === "string") this.maxLength = value; + else if (this.type === "array") this.maxItems = value; + else this.maximum = value; + return this; + }, + int() { this.type = "integer"; return this; }, +}); +const tool = (definition) => definition; +tool.schema = { + string: () => __bitfunSchema({ type: "string" }), + number: () => __bitfunSchema({ type: "number" }), + boolean: () => __bitfunSchema({ type: "boolean" }), + enum: (values) => __bitfunSchema({ type: "string", enum: values }), + array: (items) => __bitfunSchema({ type: "array", items }), + object: (properties) => __bitfunSchema({ type: "object", properties, additionalProperties: false }), +}; +"#; + +#[derive(Debug, Clone)] +pub struct OpenCodeToolProviderOptions { + pub user_config_dir: PathBuf, + pub legacy_user_config_dir: Option, + pub explicit_config_dir: Option, + pub project_config_enabled: bool, +} + +impl OpenCodeToolProviderOptions { + pub fn from_environment() -> Self { + let home = dirs::home_dir(); + let user_config_dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".config"))) + .unwrap_or_else(|| PathBuf::from(".config")) + .join("opencode"); + Self { + user_config_dir, + legacy_user_config_dir: home.map(|home| home.join(".opencode")), + explicit_config_dir: std::env::var_os("OPENCODE_CONFIG_DIR").map(PathBuf::from), + project_config_enabled: !environment_truthy("OPENCODE_DISABLE_PROJECT_CONFIG"), + } + } +} + +impl Default for OpenCodeToolProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct OpenCodeToolProvider { + options: OpenCodeToolProviderOptions, + #[cfg(test)] + directory_reader: Option std::io::Result + Send + Sync>>, +} + +impl OpenCodeToolProvider { + pub fn new(options: OpenCodeToolProviderOptions) -> Self { + Self { + options, + #[cfg(test)] + directory_reader: None, + } + } + + #[cfg(test)] + fn with_directory_reader( + mut self, + reader: impl Fn(&Path) -> std::io::Result + Send + Sync + 'static, + ) -> Self { + self.directory_reader = Some(Arc::new(reader)); + self + } + + fn read_directory(&self, path: &Path) -> std::io::Result { + #[cfg(test)] + if let Some(reader) = &self.directory_reader { + return reader(path); + } + fs::read_dir(path) + } + + fn tool_directories(&self, context: &ExternalSourceContext) -> Vec { + let mut directories = Vec::new(); + push_tool_directories( + &mut directories, + &self.options.user_config_dir, + ExternalSourceScope::UserGlobal, + "OpenCode user tools", + ); + if let Some(explicit_config_dir) = &self.options.explicit_config_dir { + push_tool_directories( + &mut directories, + explicit_config_dir, + ExternalSourceScope::UserGlobal, + "OpenCode explicit user tools", + ); + } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = find_project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + push_tool_directories( + &mut directories, + &directory.join(".opencode"), + ExternalSourceScope::Project, + "OpenCode project tools", + ); + } + } + } + if let Some(legacy) = &self.options.legacy_user_config_dir { + if legacy != &self.options.user_config_dir + && self.options.explicit_config_dir.as_ref() != Some(legacy) + { + push_tool_directories( + &mut directories, + legacy, + ExternalSourceScope::UserGlobal, + "OpenCode legacy tools", + ); + } + } + deduplicate_directories(directories) + } +} + +impl Default for OpenCodeToolProvider { + fn default() -> Self { + Self::new(OpenCodeToolProviderOptions::default()) + } +} + +impl ExternalToolSourceProvider for OpenCodeToolProvider { + fn identity(&self) -> ExternalToolProviderIdentity { + ExternalToolProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "OpenCode") + .expect("static OpenCode tool provider identity must be valid") + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + if context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "opencode.tool.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + let provider = self.identity(); + let mut sources = Vec::new(); + let mut tools = Vec::new(); + let mut diagnostics = Vec::new(); + let working_directory = self.working_directory(context); + + for directory in self.tool_directories(context) { + let source_key = source_key(&directory.path)?; + match fs::metadata(&directory.path) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.directory_metadata_failed", + format!( + "Failed to inspect tool directory '{}': {error}", + directory.path.display() + ), + Some(source_key), + )); + continue; + } + } + let mut entries = Vec::new(); + let mut source_has_restrictions = false; + let directory_entries = match self.read_directory(&directory.path) { + Ok(entries) => entries, + Err(error) => { + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.directory_read_failed", + format!( + "Failed to read tool directory '{}': {error}", + directory.path.display() + ), + Some(source_key), + )); + continue; + } + }; + for entry in directory_entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + source_has_restrictions = true; + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.directory_entry_failed", + format!( + "Failed to read an entry in tool directory '{}': {error}", + directory.path.display() + ), + Some(source_key.clone()), + )); + continue; + } + }; + let path = entry.path(); + if !is_tool_extension(&path) { + continue; + } + match fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => {} + Ok(_) => continue, + Err(error) => { + source_has_restrictions = true; + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.file_metadata_failed", + format!("Failed to inspect tool file '{}': {error}", path.display()), + Some(source_key.clone()), + )); + continue; + } + } + entries.push(path); + if entries.len() > MAX_TOOL_FILES { + break; + } + } + entries.sort(); + if entries.len() > MAX_TOOL_FILES { + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.file_limit", + format!( + "Tool directory '{}' contains more than {MAX_TOOL_FILES} supported files; additional files were ignored", + directory.path.display() + ), + Some(source_key.clone()), + )); + entries.truncate(MAX_TOOL_FILES); + } + let mut source_versions = Vec::new(); + for path in entries { + let content = match read_bounded_tool_file(&path) { + Ok(Some(content)) => content, + Ok(None) => { + source_has_restrictions = true; + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.file_too_large", + format!( + "Tool file '{}' exceeds the supported size limit", + path.display() + ), + Some(source_key.clone()), + )); + continue; + } + Err(error) => { + source_has_restrictions = true; + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.file_read_failed", + format!("Failed to read '{}': {error}", path.display()), + Some(source_key.clone()), + )); + continue; + } + }; + let content_version = content_version(content.as_bytes()); + source_versions.push((path.clone(), content_version.clone())); + let exports = discover_exports(&content); + if exports.is_empty() { + source_has_restrictions = true; + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.export_missing", + format!("Tool file '{}' has no supported exports", path.display()), + Some(source_key.clone()), + )); + continue; + } + let status = static_status(&path, &content); + source_has_restrictions |= !matches!(status, ExternalToolStaticStatus::Ready); + let namespace = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("tool"); + let target = SourceQualifiedToolTargetId::new( + source_key.clone(), + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or(namespace), + ) + .map_err(contract_error)?; + let description_preview = description_preview(&content); + for export_name in exports { + let name = if export_name == "default" { + namespace.to_string() + } else { + format!("{namespace}_{export_name}") + }; + if !is_model_callable_tool_name(&name) { + source_has_restrictions = true; + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.tool.name_unsupported", + format!( + "Tool export '{}' in '{}' does not map to a portable tool name", + export_name, + path.display() + ), + Some(source_key.clone()), + )); + continue; + } + tools.push(ExternalToolDefinition { + id: SourceQualifiedToolId::new(target.clone(), export_name) + .map_err(contract_error)?, + name, + description_preview: description_preview.clone(), + module_path: normalize_path(&path).to_string_lossy().into_owned(), + working_directory: normalize_path(&working_directory) + .to_string_lossy() + .into_owned(), + runtime_kind: runtime_kind(&path), + capabilities: vec![ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ExternalToolCapability::Process, + ExternalToolCapability::Environment, + ], + content_version: content_version.clone(), + static_status: status.clone(), + }); + } + } + if !source_versions.is_empty() { + sources.push(ExternalSourceRecord { + key: source_key, + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID).map_err(contract_error)?, + display_name: directory.display_name, + source_kind: "standalone_tools".to_string(), + scope: directory.scope, + location: normalize_path(&directory.path) + .to_string_lossy() + .into_owned(), + execution_domain_id: context.execution_domain_id.clone(), + health: if source_has_restrictions { + ExternalSourceHealth::Partial + } else { + ExternalSourceHealth::Available + }, + content_version: aggregate_content_version(&source_versions), + diagnostics: Vec::new(), + }); + } + } + tools.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + let snapshot = ExternalToolProviderSnapshot { + provider, + sources, + tools, + diagnostics, + }; + snapshot.validate().map_err(contract_error)?; + Ok(snapshot) + } + + fn prepare_target( + &self, + context: &ExternalSourceContext, + target_id: &SourceQualifiedToolTargetId, + expected_content_version: &str, + ) -> Result { + if target_id.source.provider_id.as_str() != PROVIDER_ID { + return Err(ExternalSourceProviderError::new( + "opencode.tool.target_invalid", + "tool target belongs to another provider", + false, + )); + } + let directory = self + .tool_directories(context) + .into_iter() + .find(|directory| { + source_key(&directory.path) + .ok() + .is_some_and(|source| source == target_id.source) + }) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "opencode.tool.source_missing", + "tool source is no longer configured", + true, + ) + })?; + let local_id = target_id.local_id.as_str(); + let relative = Path::new(local_id); + if relative.components().count() != 1 + || relative.file_name().and_then(|name| name.to_str()) != Some(local_id) + || !is_tool_extension(relative) + { + return Err(ExternalSourceProviderError::new( + "opencode.tool.target_invalid", + "tool target is not a supported file in its source directory", + false, + )); + } + let path = directory.path.join(relative); + let content = read_bounded_tool_file(&path) + .map_err(|error| io_error("opencode.tool.file_read_failed", &path, error))? + .ok_or_else(|| { + ExternalSourceProviderError::new( + "opencode.tool.file_too_large", + "tool changed after preview and now exceeds the file size limit", + true, + ) + })?; + if content_version(content.as_bytes()) != expected_content_version { + return Err(ExternalSourceProviderError::new( + "opencode.tool.stale_revision", + "tool changed after preview; refresh before enabling it", + true, + )); + } + if !matches!( + static_status(&path, &content), + ExternalToolStaticStatus::Ready + ) { + return Err(ExternalSourceProviderError::new( + "opencode.tool.unsupported", + "tool is outside the supported single-file JavaScript subset", + false, + )); + } + let namespace = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("tool"); + let expected_tools = discover_exports(&content) + .into_iter() + .filter_map(|export_name| { + let tool_name = if export_name == "default" { + namespace.to_string() + } else { + format!("{namespace}_{export_name}") + }; + is_model_callable_tool_name(&tool_name).then_some(PreparedExternalToolExport { + export_name, + tool_name, + }) + }) + .collect::>(); + if expected_tools.is_empty() { + return Err(ExternalSourceProviderError::new( + "opencode.tool.export_missing", + "tool has no supported exports", + false, + )); + } + let replaced = allowed_import_regex().replace_all(&content, ""); + let removed_import = matches!(replaced, std::borrow::Cow::Owned(_)); + let module_source = replaced.into_owned(); + let module_source = if removed_import { + format!("{TOOL_SHIM}\n{module_source}") + } else { + module_source + }; + Ok(PreparedExternalToolTarget { + target_id: target_id.clone(), + content_version: expected_content_version.to_string(), + module_source, + module_url: file_url(&path)?, + working_directory: normalize_path(&self.working_directory(context)) + .to_string_lossy() + .into_owned(), + worktree_root: context.workspace_root.as_ref().map(|workspace| { + normalize_path(&find_project_root(workspace)) + .to_string_lossy() + .into_owned() + }), + expected_tools, + }) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + let mut roots = BTreeMap::new(); + for directory in self.tool_directories(context) { + if let Some(parent) = directory.path.parent() { + if let Some(existing) = nearest_existing_path(parent.to_path_buf()) { + roots.entry(existing).or_insert(false); + } + } + roots + .entry(directory.path) + .and_modify(|recursive| *recursive = true) + .or_insert(true); + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } +} + +impl OpenCodeToolProvider { + fn working_directory(&self, context: &ExternalSourceContext) -> PathBuf { + let effective_global_config = self + .options + .explicit_config_dir + .as_ref() + .unwrap_or(&self.options.user_config_dir); + context + .workspace_root + .clone() + .or_else(|| effective_global_config.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| effective_global_config.clone()) + } +} + +#[derive(Debug, Clone)] +struct ToolDirectory { + path: PathBuf, + scope: ExternalSourceScope, + display_name: String, +} + +fn push_tool_directories( + directories: &mut Vec, + base: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + for folder in ["tool", "tools"] { + directories.push(ToolDirectory { + path: base.join(folder), + scope, + display_name: format!("{display_name} ({folder})"), + }); + } +} + +fn deduplicate_directories(directories: Vec) -> Vec { + let mut seen = BTreeSet::new(); + directories + .into_iter() + .filter(|directory| seen.insert(normalize_path(&directory.path))) + .collect() +} + +fn source_key(path: &Path) -> Result { + let location = normalize_path(path).to_string_lossy().into_owned(); + let digest = Sha256::digest(location.as_bytes()); + SourceKey::new( + PROVIDER_ID, + format!("directory-{}", hex::encode(&digest[..12])), + ) + .map_err(contract_error) +} + +fn discover_exports(content: &str) -> Vec { + let mut exports = BTreeSet::new(); + if default_export_regex().is_match(content) { + exports.insert("default".to_string()); + } + for captures in named_export_regex().captures_iter(content) { + if let Some(name) = captures.get(1) { + exports.insert(name.as_str().to_string()); + } + } + exports.into_iter().collect() +} + +fn static_status(path: &Path, content: &str) -> ExternalToolStaticStatus { + if path.extension().and_then(|value| value.to_str()) == Some("ts") { + return ExternalToolStaticStatus::Unsupported { + reason: "TypeScript tools are recognized but are not executable yet".to_string(), + }; + } + let without_allowed_import = allowed_import_regex().replace_all(content, ""); + if other_import_regex().is_match(&without_allowed_import) + || dynamic_import_regex().is_match(&without_allowed_import) + || require_regex().is_match(&without_allowed_import) + { + return ExternalToolStaticStatus::Unsupported { + reason: "Only single-file JavaScript tools without supported imports can run" + .to_string(), + }; + } + ExternalToolStaticStatus::Ready +} + +fn description_preview(content: &str) -> String { + description_regex() + .captures(content) + .and_then(|captures| captures.get(1)) + .map(|value| value.as_str().to_string()) + .unwrap_or_default() +} + +fn content_version(content: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(content))) +} + +fn aggregate_content_version(entries: &[(PathBuf, String)]) -> String { + let mut hasher = Sha256::new(); + for (path, version) in entries { + hasher.update(normalize_path(path).to_string_lossy().as_bytes()); + hasher.update([0]); + hasher.update(version.as_bytes()); + hasher.update([0]); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn normalize_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| { + let mut normalized = PathBuf::new(); + for component in path.components() { + normalized.push(component.as_os_str()); + } + normalized + }) +} + +fn runtime_kind(path: &Path) -> ExternalToolRuntimeKind { + match path.extension().and_then(|value| value.to_str()) { + Some("ts") => ExternalToolRuntimeKind::TypeScript, + _ => ExternalToolRuntimeKind::JavaScript, + } +} + +fn is_tool_extension(path: &Path) -> bool { + matches!( + path.extension().and_then(|value| value.to_str()), + Some("js" | "ts") + ) +} + +fn is_model_callable_tool_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn read_bounded_tool_file(path: &Path) -> std::io::Result> { + let file = fs::File::open(path)?; + let mut bytes = Vec::new(); + file.take(MAX_TOOL_FILE_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_TOOL_FILE_BYTES { + return Ok(None); + } + String::from_utf8(bytes) + .map(Some) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) +} + +fn file_url(path: &Path) -> Result { + let normalized = normalize_path(path); + url::Url::from_file_path(&normalized) + .map(|url| url.to_string()) + .map_err(|_| { + ExternalSourceProviderError::new( + "opencode.tool.module_url_invalid", + format!( + "Could not convert '{}' to an absolute file URL", + normalized.display() + ), + false, + ) + }) +} + +fn io_error(code: &str, path: &Path, error: std::io::Error) -> ExternalSourceProviderError { + ExternalSourceProviderError::new( + code, + format!("Failed to read '{}': {error}", path.display()), + true, + ) +} + +fn contract_error(error: impl std::fmt::Display) -> ExternalSourceProviderError { + ExternalSourceProviderError::new("opencode.tool.contract_invalid", error.to_string(), false) +} + +fn environment_truthy(key: &str) -> bool { + std::env::var(key) + .ok() + .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) +} + +fn find_project_root(start: &Path) -> PathBuf { + let mut current = normalize_path(start); + loop { + if current.join(".git").exists() { + return current; + } + if !current.pop() { + return normalize_path(start); + } + } +} + +fn directories_between(root: &Path, opened: &Path) -> Vec { + let root = normalize_path(root); + let mut current = normalize_path(opened); + let mut directories = Vec::new(); + while current.starts_with(&root) { + directories.push(current.clone()); + if current == root || !current.pop() { + break; + } + } + directories.reverse(); + directories +} + +fn nearest_existing_path(mut path: PathBuf) -> Option { + loop { + if path.exists() { + return Some(path); + } + if !path.pop() { + return None; + } + } +} + +fn allowed_import_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r#"(?m)^\s*import\s*\{\s*tool\s*\}\s*from\s*["']@opencode-ai/plugin["']\s*;?\s*$"#, + ) + .expect("valid allowed import regex") + }) +} + +fn other_import_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"(?m)^\s*(?:import|export\s+.+\s+from)\b").unwrap()) +} + +fn dynamic_import_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?s)\bimport(?:\s|/\*.*?\*/|//[^\r\n]*(?:\r?\n|$))*\(").unwrap() + }) +} + +fn require_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"\brequire\s*\(").unwrap()) +} + +fn default_export_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"(?m)\bexport\s+default\s+(?:tool\s*\(|\{)").unwrap()) +} + +fn named_export_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?m)\bexport\s+const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*tool\s*\(").unwrap() + }) +} + +fn description_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r#"description\s*:\s*["']([^"'\r\n]{1,512})["']"#).unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_url_percent_encodes_reserved_and_unicode_path_characters() { + let path = std::env::current_dir() + .unwrap() + .join("nested folder") + .join("tool%#?-测试.js"); + + let encoded = file_url(&path).expect("absolute path must become a file URL"); + let parsed = url::Url::parse(&encoded).expect("valid file URL"); + + assert!(encoded.starts_with("file:")); + assert!(encoded.contains("%25")); + assert!(encoded.contains("%23")); + assert!(encoded.contains("%3F")); + assert!(encoded.contains("%20")); + assert_eq!(parsed.to_file_path().expect("file URL path"), path); + } + + #[test] + fn directory_read_failure_preserves_tools_from_healthy_directories() { + let temp = tempfile::tempdir().unwrap(); + let global = temp.path().join("global"); + let workspace = temp.path().join("workspace"); + let failed_directory = global.join("tools"); + let healthy_directory = workspace.join(".opencode/tools"); + fs::create_dir_all(&failed_directory).unwrap(); + fs::create_dir_all(&healthy_directory).unwrap(); + fs::write( + healthy_directory.join("healthy.js"), + r#"export default { description: "healthy", args: {}, execute() { return "ok" } }"#, + ) + .unwrap(); + + let failed_source = source_key(&failed_directory).unwrap(); + let failed_directory_for_reader = failed_directory.clone(); + let provider = OpenCodeToolProvider::new(OpenCodeToolProviderOptions { + user_config_dir: global, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: true, + }) + .with_directory_reader(move |path| { + if path == failed_directory_for_reader { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "directory is unreadable", + )); + } + fs::read_dir(path) + }); + let context = ExternalSourceContext { + workspace_root: Some(workspace), + execution_domain_id: bitfun_product_domains::external_sources::ExecutionDomainId::new( + "local-user", + ) + .unwrap(), + }; + + let snapshot = provider + .discover(&context) + .expect("one unreadable directory must not discard healthy directory results"); + + assert_eq!( + snapshot + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["healthy"] + ); + assert!(snapshot + .sources + .iter() + .all(|source| source.key != failed_source)); + let diagnostic = snapshot + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "opencode.tool.directory_read_failed") + .expect("failed directory must produce a diagnostic"); + assert_eq!(diagnostic.source.as_ref(), Some(&failed_source)); + } +} diff --git a/src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs b/src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs new file mode 100644 index 0000000000..1422a1d568 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs @@ -0,0 +1,509 @@ +use bitfun_opencode_adapter::{OpenCodeToolProvider, OpenCodeToolProviderOptions}; +use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalSourceContext, ExternalSourceScope, ExternalToolSourceProvider, + ExternalToolStaticStatus, +}; +use bitfun_runtime_ports::{ + ScriptToolExpectedExport, ScriptToolInvokeRequest, ScriptToolLoadRequest, ScriptToolRuntime, + ScriptToolRuntimeAvailability, +}; +use bitfun_services_integrations::script_tool::NodeScriptToolRuntime; +use serde_json::json; +use std::fs; +use std::path::PathBuf; + +fn context(workspace: PathBuf) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(workspace), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +fn provider(user_config_dir: PathBuf) -> OpenCodeToolProvider { + OpenCodeToolProvider::new(OpenCodeToolProviderOptions { + user_config_dir, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: true, + }) +} + +#[test] +fn discovers_global_and_project_js_tools_without_executing_them() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user-opencode"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(user.join("tools")).unwrap(); + fs::create_dir_all(workspace.join(".opencode/tool")).unwrap(); + fs::write( + user.join("tools/weather.js"), + r#" +import { tool } from "@opencode-ai/plugin" +export default tool({ + description: "Get weather", + args: { location: tool.schema.string() }, + async execute(args) { return args.location }, +}) +"#, + ) + .unwrap(); + let marker = temp.path().join("must-not-exist"); + fs::write( + workspace.join(".opencode/tool/utility.js"), + format!( + r#" +import {{ tool }} from "@opencode-ai/plugin" +import {{ writeFileSync }} from "node:fs" +writeFileSync({marker:?}, "executed") +export const echo = tool({{ + description: "Echo text", + args: {{ text: tool.schema.string() }}, + async execute(args) {{ return args.text }}, +}}) +"# + ), + ) + .unwrap(); + + let snapshot = provider(user) + .discover(&context(workspace)) + .expect("static discovery should succeed"); + + let names = snapshot + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["utility_echo", "weather"]); + assert!(matches!( + snapshot.tools[0].static_status, + ExternalToolStaticStatus::Unsupported { .. } + )); + assert!(matches!( + snapshot.tools[1].static_status, + ExternalToolStaticStatus::Ready + )); + assert!(!marker.exists(), "discovery must never import tool code"); +} + +#[test] +fn recognizes_typescript_but_keeps_it_explicitly_unavailable_in_pr2() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user-opencode"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(user.join("tool")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("tool/search.ts"), + r#"export default { description: "Search", args: {}, execute() { return "ok" } }"#, + ) + .unwrap(); + + let snapshot = provider(user).discover(&context(workspace)).unwrap(); + assert_eq!(snapshot.tools.len(), 1); + assert!(matches!( + &snapshot.tools[0].static_status, + ExternalToolStaticStatus::Unsupported { reason } + if reason.contains("TypeScript") + )); +} + +#[test] +fn prepares_only_the_supported_single_file_javascript_subset_and_checks_revision() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user-opencode"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(user.join("tools")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let path = user.join("tools/weather.js"); + fs::write( + &path, + r#" +import { tool } from "@opencode-ai/plugin" +export default tool({ + description: "Get weather", + args: { location: tool.schema.string().describe("Location") }, + async execute(args) { return args.location }, +}) +"#, + ) + .unwrap(); + let provider = provider(user); + let context = context(workspace); + let snapshot = provider.discover(&context).unwrap(); + let definition = snapshot.tools[0].clone(); + + let prepared = provider + .prepare_target(&context, &definition.id.target, &definition.content_version) + .expect("supported source should be prepared after approval"); + assert!(!prepared.module_source.contains("@opencode-ai/plugin")); + assert!(prepared.module_source.contains("const tool =")); + assert_eq!(prepared.expected_tools[0].tool_name, "weather"); + + fs::write(&path, "export default {").unwrap(); + let error = provider + .prepare_target(&context, &definition.id.target, &definition.content_version) + .unwrap_err(); + assert_eq!(error.code, "opencode.tool.stale_revision"); +} + +#[test] +fn dynamic_import_with_comments_is_recognized_as_unsupported() { + let temp = tempfile::tempdir().unwrap(); + let global = temp.path().join("global"); + let tools = global.join("tools"); + std::fs::create_dir_all(&tools).unwrap(); + std::fs::write( + tools.join("dynamic.js"), + r#"export default { description: "dynamic", args: {}, async execute() { return import /* hidden */ ("node:fs"); } };"#, + ) + .unwrap(); + let provider = OpenCodeToolProvider::new(OpenCodeToolProviderOptions { + user_config_dir: global, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + }); + let snapshot = provider + .discover(&context(temp.path().join("workspace"))) + .unwrap(); + + assert!(matches!( + snapshot.tools[0].static_status, + ExternalToolStaticStatus::Unsupported { .. } + )); +} + +#[test] +fn explicit_config_directory_is_appended_to_the_default_global_directory() { + let temp = tempfile::tempdir().unwrap(); + let default_global = temp.path().join("default-global"); + let explicit_global = temp.path().join("explicit-global"); + fs::create_dir_all(default_global.join("tools")).unwrap(); + fs::create_dir_all(explicit_global.join("tools")).unwrap(); + fs::write( + default_global.join("tools/default.js"), + r#"export default { description: "default", args: {}, execute() { return "default" } }"#, + ) + .unwrap(); + fs::write( + explicit_global.join("tools/explicit.js"), + r#"export default { description: "explicit", args: {}, execute() { return "explicit" } }"#, + ) + .unwrap(); + let provider = OpenCodeToolProvider::new(OpenCodeToolProviderOptions { + user_config_dir: default_global, + legacy_user_config_dir: None, + explicit_config_dir: Some(explicit_global), + project_config_enabled: false, + }); + + let snapshot = provider + .discover(&context(temp.path().join("workspace"))) + .unwrap(); + assert_eq!( + snapshot + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["default", "explicit"] + ); + assert!(snapshot + .sources + .iter() + .all(|source| source.scope == ExternalSourceScope::UserGlobal)); +} + +#[test] +fn non_portable_tool_names_are_reported_without_poisoning_other_tools() { + let temp = tempfile::tempdir().unwrap(); + let global = temp.path().join("global"); + fs::create_dir_all(global.join("tools")).unwrap(); + fs::write( + global.join("tools/bad name.js"), + r#"export default { description: "bad", args: {}, execute() { return "bad" } }"#, + ) + .unwrap(); + fs::write( + global.join("tools/safe.js"), + r#"export default { description: "safe", args: {}, execute() { return "safe" } }"#, + ) + .unwrap(); + + let snapshot = provider(global) + .discover(&context(temp.path().join("workspace"))) + .unwrap(); + assert_eq!(snapshot.tools.len(), 1); + assert_eq!(snapshot.tools[0].name, "safe"); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.tool.name_unsupported")); +} + +#[tokio::test] +async fn prepared_schema_chains_preserve_defaults_and_type_specific_bounds() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let temp = tempfile::tempdir().unwrap(); + let global = temp.path().join("global"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(global.join("tools")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + global.join("tools/schema.js"), + r#" +import { tool } from "@opencode-ai/plugin" +export default tool({ + description: "Checks schema chains", + args: { + greeting: tool.schema.string().min(2).default("hello"), + tags: tool.schema.array(tool.schema.string()).min(2).max(3), + }, + execute(args) { return `${args.greeting}:${args.tags.join(",")}` }, +}) +"#, + ) + .unwrap(); + let provider = provider(global); + let context = context(workspace); + let definition = provider.discover(&context).unwrap().tools.remove(0); + let prepared = provider + .prepare_target(&context, &definition.id.target, &definition.content_version) + .unwrap(); + let target_id = prepared.target_id.stable_key(); + let revision = prepared.content_version.clone(); + let loaded = runtime + .load(ScriptToolLoadRequest { + target_id: target_id.clone(), + revision: revision.clone(), + module_source: prepared.module_source, + module_url: prepared.module_url, + working_directory: prepared.working_directory, + expected_tools: prepared + .expected_tools + .into_iter() + .map(|tool| ScriptToolExpectedExport { + export_name: tool.export_name, + tool_name: tool.tool_name, + }) + .collect(), + }) + .await + .unwrap(); + assert_eq!(loaded.tools[0].input_schema["required"], json!(["tags"])); + assert_eq!( + loaded.tools[0].input_schema["properties"]["greeting"]["minLength"], + json!(2) + ); + assert_eq!( + loaded.tools[0].input_schema["properties"]["tags"]["minItems"], + json!(2) + ); + + let response = runtime + .invoke(ScriptToolInvokeRequest { + target_id, + revision, + export_name: "default".to_string(), + operation_id: "schema-default".to_string(), + arguments: json!({"tags": ["a", "b"]}), + workspace_root: None, + worktree_root: None, + session_id: None, + }) + .await + .unwrap(); + assert_eq!(response.output, "hello:a,b"); +} + +#[tokio::test] +async fn prepared_optional_schema_can_be_omitted_at_runtime() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let temp = tempfile::tempdir().unwrap(); + let global = temp.path().join("global"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(global.join("tools")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + global.join("tools/optional.js"), + r#" +import { tool } from "@opencode-ai/plugin" +export default tool({ + description: "Checks optional schema arguments", + args: { + required: tool.schema.string(), + note: tool.schema.string().optional(), + }, + execute(args) { return `${args.required}:${args.note ?? "missing"}` }, +}) +"#, + ) + .unwrap(); + let provider = provider(global); + let context = context(workspace); + let definition = provider.discover(&context).unwrap().tools.remove(0); + let prepared = provider + .prepare_target(&context, &definition.id.target, &definition.content_version) + .unwrap(); + let target_id = prepared.target_id.stable_key(); + let revision = prepared.content_version.clone(); + let loaded = runtime + .load(ScriptToolLoadRequest { + target_id: target_id.clone(), + revision: revision.clone(), + module_source: prepared.module_source, + module_url: prepared.module_url, + working_directory: prepared.working_directory, + expected_tools: prepared + .expected_tools + .into_iter() + .map(|tool| ScriptToolExpectedExport { + export_name: tool.export_name, + tool_name: tool.tool_name, + }) + .collect(), + }) + .await + .unwrap(); + assert_eq!( + loaded.tools[0].input_schema["required"], + json!(["required"]) + ); + + let response = runtime + .invoke(ScriptToolInvokeRequest { + target_id, + revision, + export_name: "default".to_string(), + operation_id: "schema-optional".to_string(), + arguments: json!({"required": "hello"}), + workspace_root: None, + worktree_root: None, + session_id: None, + }) + .await + .expect("optional arguments without defaults may be omitted"); + assert_eq!(response.output, "hello:missing"); +} + +#[tokio::test] +async fn prepared_nested_schema_preserves_required_optional_and_defaults() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let temp = tempfile::tempdir().unwrap(); + let global = temp.path().join("global"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(global.join("tools")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + global.join("tools/nested.js"), + r#" +import { tool } from "@opencode-ai/plugin" +export default tool({ + description: "Checks nested schema arguments", + args: { + payload: tool.schema.object({ + required: tool.schema.string(), + optional: tool.schema.string().optional(), + defaulted: tool.schema.string().default("fallback"), + items: tool.schema.array(tool.schema.object({ + name: tool.schema.string(), + note: tool.schema.string().optional(), + })), + }), + }, + execute(args) { + return `${args.payload.required}:${args.payload.optional ?? "missing"}:${args.payload.defaulted}:${args.payload.items[0].name}` + }, +}) +"#, + ) + .unwrap(); + let provider = provider(global); + let context = context(workspace); + let definition = provider.discover(&context).unwrap().tools.remove(0); + let prepared = provider + .prepare_target(&context, &definition.id.target, &definition.content_version) + .unwrap(); + let target_id = prepared.target_id.stable_key(); + let revision = prepared.content_version.clone(); + let loaded = runtime + .load(ScriptToolLoadRequest { + target_id: target_id.clone(), + revision: revision.clone(), + module_source: prepared.module_source, + module_url: prepared.module_url, + working_directory: prepared.working_directory, + expected_tools: prepared + .expected_tools + .into_iter() + .map(|tool| ScriptToolExpectedExport { + export_name: tool.export_name, + tool_name: tool.tool_name, + }) + .collect(), + }) + .await + .unwrap(); + let schema = &loaded.tools[0].input_schema; + assert_eq!(schema["required"], json!(["payload"])); + assert_eq!( + schema["properties"]["payload"]["required"], + json!(["required", "items"]) + ); + assert_eq!( + schema["properties"]["payload"]["properties"]["items"]["items"]["required"], + json!(["name"]) + ); + assert!(!serde_json::to_string(schema).unwrap().contains("__")); + + let missing_required = runtime + .invoke(ScriptToolInvokeRequest { + target_id: target_id.clone(), + revision: revision.clone(), + export_name: "default".to_string(), + operation_id: "nested-required".to_string(), + arguments: json!({"payload": {"items": [{"name": "first"}]}}), + workspace_root: None, + worktree_root: None, + session_id: None, + }) + .await + .expect_err("nested required fields must be validated"); + assert!(missing_required + .message + .contains("payload.required is required")); + + let response = runtime + .invoke(ScriptToolInvokeRequest { + target_id, + revision, + export_name: "default".to_string(), + operation_id: "nested-default".to_string(), + arguments: json!({"payload": {"required": "hello", "items": [{"name": "first"}]}}), + workspace_root: None, + worktree_root: None, + session_id: None, + }) + .await + .unwrap(); + assert_eq!(response.output, "hello:missing:fallback:first"); +} diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 636d8517ef..7415e5bb4e 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -41,7 +41,12 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; -fn tool_call_needs_permission(registry: &ToolRegistry, tool_call: &ToolCall) -> bool { +fn tool_call_needs_permission( + registry: &ToolRegistry, + tool_call: &ToolCall, + workspace_root: Option<&std::path::Path>, + is_remote: bool, +) -> bool { let invocation = ResolvedToolInvocation::from_wire_call( tool_call.tool_name.clone(), tool_call.arguments.clone(), @@ -52,6 +57,12 @@ fn tool_call_needs_permission(registry: &ToolRegistry, tool_call: &ToolCall) -> registry .get_tool(&invocation.effective_tool_name) + .and_then(|tool| { + crate::external_tools::resolve_external_tool_for_workspace( + tool, + crate::external_tools::external_tool_route_root(workspace_root, is_remote), + ) + }) .map(|tool| tool.needs_permissions(Some(&invocation.effective_arguments))) .unwrap_or(false) } @@ -817,10 +828,20 @@ impl RoundExecutor { let registry = get_global_tool_registry(); let tool_registry = registry.read().await; - stream_result - .tool_calls - .iter() - .any(|tool_call| tool_call_needs_permission(&tool_registry, tool_call)) + stream_result.tool_calls.iter().any(|tool_call| { + tool_call_needs_permission( + &tool_registry, + tool_call, + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .is_some_and(|workspace| workspace.is_remote()), + ) + }) }; let needs_confirm = resolve_tool_confirmation_policy_gate(ToolConfirmationPolicyGateFacts { @@ -1396,7 +1417,7 @@ mod tests { recovered_from_truncation: false, }; - assert!(tool_call_needs_permission(®istry, &call)); + assert!(tool_call_needs_permission(®istry, &call, None, false)); } fn test_round_context() -> RoundContext { diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 51fcc99df5..e3d77a1284 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -572,8 +572,23 @@ impl ToolPipeline { if resolution_error.is_some() { return false; } + let route_root = crate::external_tools::external_tool_route_root( + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .is_some_and(|workspace| workspace.is_remote()), + ); let tool_is_concurrency_safe = registry .get_tool(&invocation.effective_tool_name) + .and_then(|tool| { + crate::external_tools::resolve_external_tool_for_workspace( + tool, route_root, + ) + }) .map(|tool| tool.is_concurrency_safe(Some(&invocation.effective_arguments))) .unwrap_or(false); tool_call_concurrency_safe_for_batch( @@ -860,7 +875,7 @@ impl ToolPipeline { return Err(map_tool_execution_admission_rejection(err)); } - let tool = tool.ok_or_else(|| { + let registered_tool = tool.ok_or_else(|| { let error_msg = format!("Tool '{}' is not registered or enabled.", tool_name); error!("{}", error_msg); BitFunError::tool(error_msg) @@ -868,6 +883,10 @@ impl ToolPipeline { let cancellation_token = CancellationToken::new(); let tool_context = self.build_tool_use_context(&task, cancellation_token.clone()); + // Keep the registered mux in the execution path. It rechecks the + // persisted conflict choice immediately before dispatch and applies + // remote fail-closed routing from the full ToolUseContext. + let tool = registered_tool; let validation = tool.validate_input(&tool_args, Some(&tool_context)).await; if !validation.result { let error_msg = validation @@ -1345,7 +1364,17 @@ impl ToolPipeline { let execution_future = tool.call(task.effective_arguments(), &tool_context); - let pipeline_timeout_secs = if tool.manages_own_execution_timeout() { + let timeout_owner = crate::external_tools::resolve_external_tool_for_workspace( + Arc::clone(&tool), + crate::external_tools::external_tool_route_root( + tool_context.workspace_root(), + tool_context.is_remote(), + ), + ); + let pipeline_timeout_secs = if timeout_owner + .as_ref() + .is_some_and(|selected| selected.manages_own_execution_timeout()) + { None } else { task.options.timeout_secs @@ -1630,6 +1659,7 @@ mod tests { use crate::agentic::tools::implementations::task::TaskTool; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; use async_trait::async_trait; use bitfun_agent_tools::{LoadedDeferredToolSpec, CALL_DEFERRED_TOOL_NAME}; use bitfun_runtime_ports::{ @@ -1638,6 +1668,7 @@ mod tests { }; use serde_json::json; use std::collections::HashMap; + use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::SystemTime; use tokio::time::{sleep, Duration}; @@ -1870,6 +1901,47 @@ mod tests { ) } + #[test] + fn remote_workspace_route_root_isolated_from_same_local_path() { + let pipeline = test_tool_pipeline(); + let root = std::env::current_dir().expect("absolute test workspace root"); + + let mut local_task = test_tool_task("local-route", "Read"); + local_task.context.workspace = Some(WorkspaceBinding::new(None, root.clone())); + let local = pipeline.build_tool_use_context(&local_task, CancellationToken::new()); + + let session_identity = + crate::service::remote_ssh::workspace_state::workspace_session_identity( + root.to_string_lossy().as_ref(), + Some("remote-connection"), + Some("remote.example"), + ) + .expect("remote workspace identity"); + let mut remote_task = test_tool_task("remote-route", "Read"); + remote_task.context.workspace = Some(WorkspaceBinding::new_remote( + None, + PathBuf::from(&root), + "remote-connection".to_string(), + "Remote".to_string(), + session_identity, + )); + let remote = pipeline.build_tool_use_context(&remote_task, CancellationToken::new()); + + assert_eq!( + crate::external_tools::external_tool_route_root( + local.workspace_root(), + local.is_remote(), + ), + Some(root.as_path()) + ); + let remote_route_root = crate::external_tools::external_tool_route_root( + remote.workspace_root(), + remote.is_remote(), + ); + assert_eq!(remote_route_root, Some(std::path::Path::new("\0"))); + assert!(dunce::canonicalize(remote_route_root.expect("remote sentinel")).is_err()); + } + async fn register_static_test_tool( pipeline: &ToolPipeline, name: &str, diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index bc2fbb0cf6..6e40dca936 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -7,10 +7,10 @@ use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolDefinition; use bitfun_agent_tools::{ - ContextualToolManifest, ContextualVisibleTools, GetToolSpecCatalogProvider, - GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, GetToolSpecRuntime, - ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolManifestDefinition, - CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, + resolve_contextual_tool_manifest, resolve_contextual_visible_tools, ContextualToolManifest, + ContextualVisibleTools, GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, + GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, + ToolManifestDefinition, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; use serde_json::Value; use std::sync::Arc; @@ -195,9 +195,8 @@ impl ProductToolCatalogProvider { &policy.exposure_overrides, context, ); - let visible_tools = product_tool_catalog_runtime(self) - .visible_tools(&allowed_tools, &exposure_overrides, context) - .await; + let visible_tools = + resolve_product_visible_tools(&allowed_tools, &exposure_overrides, context).await; Ok(visible_tools.deferred_tools) } @@ -218,9 +217,8 @@ impl ProductToolCatalogProvider { &policy.exposure_overrides, context, ); - let visible_tools = product_tool_catalog_runtime(self) - .visible_tools(&allowed_tools, &exposure_overrides, context) - .await; + let visible_tools = + resolve_product_visible_tools(&allowed_tools, &exposure_overrides, context).await; let mut tools = visible_tools.direct_tools; tools.extend(visible_tools.deferred_tools); Ok(tools) @@ -244,15 +242,20 @@ pub(crate) async fn resolve_product_visible_tools( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ContextualVisibleTools { - let provider = ProductToolCatalogProvider; let (allowed_tools, exposure_overrides) = ProductToolCatalogProvider::resolve_manifest_inputs( allowed_tools, exposure_overrides, context, ); - product_tool_catalog_runtime(&provider) - .visible_tools(&allowed_tools, &exposure_overrides, context) - .await + let tool_snapshot = contextual_tool_snapshot(context).await; + resolve_contextual_visible_tools( + &tool_snapshot, + &allowed_tools, + &exposure_overrides, + context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await } pub(crate) async fn resolve_product_tool_manifest( @@ -260,15 +263,39 @@ pub(crate) async fn resolve_product_tool_manifest( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ContextualToolManifest { - let provider = ProductToolCatalogProvider; let (allowed_tools, exposure_overrides) = ProductToolCatalogProvider::resolve_manifest_inputs( allowed_tools, exposure_overrides, context, ); - product_tool_catalog_runtime(&provider) - .tool_manifest(&allowed_tools, &exposure_overrides, context) - .await + let tool_snapshot = contextual_tool_snapshot(context).await; + resolve_contextual_tool_manifest( + &tool_snapshot, + &allowed_tools, + &exposure_overrides, + context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await +} + +async fn contextual_tool_snapshot(context: &ToolUseContext) -> Vec { + if !context.is_remote() { + crate::external_sources::ensure_external_source_workspace_runtime(context.workspace_root()) + .await; + } + let route_root = crate::external_tools::external_tool_route_root( + context.workspace_root(), + context.is_remote(), + ); + let registry = get_global_tool_registry(); + let tools = registry.read().await.get_all_tools(); + tools + .into_iter() + .filter_map(|tool| { + crate::external_tools::resolve_external_tool_for_workspace(tool, route_root) + }) + .collect() } pub(crate) async fn resolve_product_resolved_visible_tools( @@ -328,12 +355,14 @@ mod tests { use crate::agentic::tools::registry::create_tool_registry; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; use bitfun_agent_tools::{ GetToolSpecCatalogProvider, ToolCatalogSnapshotProvider, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; use serde_json::{json, Value}; use std::collections::HashMap; + use std::path::PathBuf; use std::sync::Arc; struct DeferredMcpCatalogTool; @@ -414,6 +443,43 @@ mod tests { tool_context(None) } + #[test] + fn remote_workspace_route_root_isolated_from_same_local_path() { + let root = std::env::current_dir().expect("absolute test workspace root"); + let mut local = tool_context(None); + local.workspace = Some(WorkspaceBinding::new(None, root.clone())); + + let session_identity = + crate::service::remote_ssh::workspace_state::workspace_session_identity( + root.to_string_lossy().as_ref(), + Some("remote-connection"), + Some("remote.example"), + ) + .expect("remote workspace identity"); + let mut remote = tool_context(None); + remote.workspace = Some(WorkspaceBinding::new_remote( + None, + PathBuf::from(&root), + "remote-connection".to_string(), + "Remote".to_string(), + session_identity, + )); + + assert_eq!( + crate::external_tools::external_tool_route_root( + local.workspace_root(), + local.is_remote(), + ), + Some(root.as_path()) + ); + let remote_route_root = crate::external_tools::external_tool_route_root( + remote.workspace_root(), + remote.is_remote(), + ); + assert_eq!(remote_route_root, Some(std::path::Path::new("\0"))); + assert!(dunce::canonicalize(remote_route_root.expect("remote sentinel")).is_err()); + } + #[tokio::test] async fn product_catalog_provider_reads_global_registry_snapshot() { let provider = ProductToolCatalogProvider; diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 79a538d9e7..8cbab904ab 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -72,10 +72,15 @@ impl ToolRegistry { ); } - self.register_tool(tool); + let routed = crate::external_tools::intercept_external_tool_registry_registration(tool); + self.inner.register_tool(routed); debug!("MCP tool registered: tool_name={}", name); } + if tool_count > 0 { + crate::external_sources::notify_external_tool_registry_changed(); + } + let after_count = self.get_tool_names().len(); let added_count = after_count - before_count; @@ -87,6 +92,8 @@ impl ToolRegistry { /// Remove all tools from the MCP server pub fn unregister_mcp_server_tools(&mut self, server_id: &str) { + let retained_external_routes = + crate::external_tools::detach_external_tool_mcp_server(server_id); let removed_tool_names = self .get_tool_names() .into_iter() @@ -99,6 +106,14 @@ impl ToolRegistry { self.inner.unregister_mcp_server_tools(server_id); + for mux in retained_external_routes.iter().cloned() { + self.register_tool_without_external_source_notification(mux); + } + + if !retained_external_routes.is_empty() { + crate::external_sources::notify_external_tool_registry_changed(); + } + for key in removed_tool_names { info!("Unregistering dynamic tool: tool_name={}", key); } @@ -106,12 +121,31 @@ impl ToolRegistry { /// Remove all tools whose registry name starts with the given prefix. pub fn unregister_tools_by_prefix(&mut self, prefix: &str) -> usize { + let retained_muxes = crate::external_tools::retain_external_tool_muxes_for_prefix(prefix); + let retained_names = retained_muxes + .iter() + .map(|tool| tool.name().to_string()) + .collect::>(); let removed_tool_names = self .get_tool_names() .into_iter() .filter(|name| name.starts_with(prefix)) .collect::>(); - let count = self.inner.unregister_tools_by_prefix(prefix); + let mut count = 0; + for name in removed_tool_names + .iter() + .filter(|name| !retained_names.contains(*name)) + { + count += usize::from(self.inner.unregister_tool(name).is_some()); + } + for mux in retained_muxes.iter().cloned() { + self.register_tool_without_external_source_notification(mux); + count += 1; + } + + if !retained_muxes.is_empty() { + crate::external_sources::notify_external_tool_registry_changed(); + } for key in removed_tool_names { info!("Unregistering dynamic tool: tool_name={}", key); @@ -120,8 +154,23 @@ impl ToolRegistry { count } + /// Remove one exact tool while preserving the displaced implementation for + /// a contextual conflict router. + pub fn unregister_tool(&mut self, name: &str) -> Option { + self.inner.unregister_tool(name) + } + /// Register a single tool pub fn register_tool(&mut self, tool: ToolRef) { + let is_router = tool.dynamic_provider_id() == Some("external-source-router"); + let routed = crate::external_tools::intercept_external_tool_registry_registration(tool); + self.inner.register_tool(routed); + if !is_router { + crate::external_sources::notify_external_tool_registry_changed(); + } + } + + pub(crate) fn register_tool_without_external_source_notification(&mut self, tool: ToolRef) { self.inner.register_tool(tool); } @@ -865,6 +914,22 @@ mod tests { ); } + #[test] + fn exact_unregister_returns_the_displaced_tool_and_clears_metadata() { + let mut registry = ToolRegistry::new(); + registry.register_tool(dynamic_tool("external_search", Some("provider-a"))); + let generation = registry.current_snapshot_generation(); + + let removed = registry + .unregister_tool("external_search") + .expect("registered tool should be returned"); + + assert_eq!(removed.name(), "external_search"); + assert!(registry.get_tool("external_search").is_none()); + assert!(registry.get_dynamic_tool_info("external_search").is_none()); + assert!(registry.current_snapshot_generation() > generation); + } + #[test] fn registry_wraps_file_modification_tools_for_snapshot_tracking() { let registry = create_tool_registry(); diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 81a4f958cb..8ac90b26b8 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -5,16 +5,27 @@ pub use bitfun_product_domains::external_sources::{ prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceCatalogEntry, - ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, ExternalSourceLifecycleState, - PromptCommandAvailability, PromptCommandCatalogEntry, PromptCommandDefinition, SourceKey, + ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, + ExternalSourceLifecycleState, ExternalToolActivationState, ExternalToolApprovalRequest, + ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolConflict, + ExternalToolRuntimeKind, PromptCommandAvailability, PromptCommandCatalogEntry, + PromptCommandDefinition, SourceKey, }; +use crate::external_tools::{ + begin_external_tool_workspace_recovery, external_tool_workspace_requires_recovery, + merge_tool_state, reconcile_external_tools, release_external_tool_workspace, + reset_external_tool_workspace_recovery_budget, ExternalToolDecisions, ExternalToolProductState, + TOOL_CONFLICT_RESELECTION_REQUIRED, UNRESOLVED_TOOL_CONFLICT_CHOICE, +}; use bitfun_external_sources::{ ExternalSourceCoordinator, ExternalSourceDiscoveryRequest, ExternalSourceDiscoveryResult, + ExternalToolCoordinator, ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, }; -use bitfun_opencode_adapter::OpenCodeCommandProvider; +use bitfun_opencode_adapter::{OpenCodeCommandProvider, OpenCodeToolProvider}; use bitfun_product_domains::external_sources::{ - ExecutionDomainId, ExternalSourceContext, PromptCommandSourceProvider, + ExecutionDomainId, ExternalSourceContext, ExternalToolSourceProvider, + PromptCommandSourceProvider, }; use bitfun_services_core::json_store::JsonFileStore; use bitfun_services_integrations::file_watch::{FileWatchService, FileWatcherConfig}; @@ -23,6 +34,7 @@ use futures::future::{join_all, BoxFuture, Shared}; use futures::FutureExt; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::future::Future; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex as StdMutex, MutexGuard, OnceLock, Weak}; @@ -42,6 +54,12 @@ struct ExternalSourcesConfig { conflict_lineage_current_keys: BTreeMap, #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] conflicted_candidate_ids: BTreeSet, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + approved_tool_targets: BTreeSet, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + declined_tool_decisions: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + tool_conflict_choices: BTreeMap, } #[derive(Debug, Clone)] @@ -84,25 +102,50 @@ impl ExternalSourcePreferenceStore { } type SharedDiscoveryTask = Shared>; +type SharedToolDiscoveryTask = Shared>; struct InFlightDiscovery { task: SharedDiscoveryTask, wake_scheduled: bool, } +struct InFlightToolDiscovery { + task: SharedToolDiscoveryTask, + wake_scheduled: bool, +} + +#[derive(Clone, Copy)] +enum WorkerRecoveryPolicy { + Preserve, + PendingOnce, + ResetAndAttempt, +} + struct WorkspaceExternalSourceService { workspace_root: Option, coordinator: Arc>, + tool_coordinator: 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, >, - initial_refresh_started: AtomicBool, + tool_discovery_tasks: tokio::sync::Mutex< + BTreeMap, + >, + initial_refresh_completed: AtomicBool, + background_refresh_scheduled: AtomicBool, + initial_refresh_gate: tokio::sync::Mutex<()>, keepalive_started: AtomicBool, last_access_epoch_seconds: AtomicU64, watcher: Arc, + #[cfg(test)] + tool_decision_gate_waiting: tokio::sync::Notify, + #[cfg(test)] + tool_decision_gate_acquired: tokio::sync::Notify, } impl WorkspaceExternalSourceService { @@ -114,47 +157,190 @@ impl WorkspaceExternalSourceService { }; let providers: Vec> = vec![Arc::new(OpenCodeCommandProvider::default())]; - let mut coordinator = ExternalSourceCoordinator::new(context, providers)?; + let mut coordinator = ExternalSourceCoordinator::new(context.clone(), providers)?; + let tool_providers: Vec> = + vec![Arc::new(OpenCodeToolProvider::default())]; + let mut tool_coordinator = ExternalToolCoordinator::new(context, tool_providers)?; let preferences = read_external_sources_config().await?; - coordinator.replace_suppressed_sources( - preferences.suppressed_source_keys.iter().cloned().collect(), + let suppressed_sources = preferences + .suppressed_source_keys + .iter() + .cloned() + .collect::>(); + coordinator.replace_suppressed_sources(suppressed_sources.clone()); + tool_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()); + let initial_snapshot = merge_tool_state( + coordinator.snapshot(), + &tool_coordinator.snapshot(), + ExternalToolProductState::default(), ); - coordinator.replace_conflict_choices(preferences.conflict_choices); - coordinator - .replace_conflict_lineage_current_keys(preferences.conflict_lineage_current_keys); - coordinator.replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids); let (updates, _) = broadcast::channel(32); let service = Arc::new(Self { workspace_root, coordinator: Arc::new(StdMutex::new(coordinator)), + tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), + 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()), - initial_refresh_started: AtomicBool::new(false), + tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + initial_refresh_completed: AtomicBool::new(false), + background_refresh_scheduled: AtomicBool::new(false), + initial_refresh_gate: tokio::sync::Mutex::new(()), keepalive_started: AtomicBool::new(false), last_access_epoch_seconds: AtomicU64::new(epoch_seconds()), watcher: Arc::new(FileWatchService::new(FileWatcherConfig::default())), + #[cfg(test)] + tool_decision_gate_waiting: tokio::sync::Notify::new(), + #[cfg(test)] + tool_decision_gate_acquired: tokio::sync::Notify::new(), }); service.start_watching().await; Ok(service) } async fn refresh(self: &Arc) -> Result { - self.initial_refresh_started.store(true, Ordering::Release); + self.refresh_with_worker_recovery(WorkerRecoveryPolicy::ResetAndAttempt) + .await + } + + async fn refresh_preserving_worker_recovery( + self: &Arc, + ) -> Result { + self.refresh_with_worker_recovery(WorkerRecoveryPolicy::Preserve) + .await + } + + async fn refresh_worker_loss_once( + self: &Arc, + ) -> Result { + self.refresh_with_worker_recovery(WorkerRecoveryPolicy::PendingOnce) + .await + } + + async fn refresh_with_worker_recovery( + self: &Arc, + recovery_policy: WorkerRecoveryPolicy, + ) -> Result { // Preferences are global to the local execution domain and may be // changed by another BitFun process. Synchronize before every refresh // so a cached CLI/Desktop service cannot keep an externally disabled // source active. sync_service_preferences(self).await?; let _refresh_guard = self.refresh_gate.lock().await; + if matches!(recovery_policy, WorkerRecoveryPolicy::ResetAndAttempt) { + reset_external_tool_workspace_recovery_budget(self.workspace_root.as_deref()).await; + } + let recovery_targets = if matches!( + recovery_policy, + WorkerRecoveryPolicy::PendingOnce | WorkerRecoveryPolicy::ResetAndAttempt + ) { + begin_external_tool_workspace_recovery(self.workspace_root.as_deref()).await + } else { + BTreeSet::new() + }; let requests = lock_coordinator(&self.coordinator).discovery_requests(); let scheduled = self.prepare_discovery_tasks(requests).await; - let polled = poll_discovery_tasks(scheduled, PROVIDER_DISCOVERY_TIMEOUT).await; + let tool_requests = lock_tool_coordinator(&self.tool_coordinator).discovery_requests(); + let tool_scheduled = self.prepare_tool_discovery_tasks(tool_requests).await; + let (polled, tool_polled) = tokio::join!( + poll_discovery_tasks(scheduled, PROVIDER_DISCOVERY_TIMEOUT), + poll_tool_discovery_tasks(tool_scheduled, PROVIDER_DISCOVERY_TIMEOUT), + ); let results = self.finish_discovery_poll(polled).await; - let snapshot = lock_coordinator(&self.coordinator).apply_discovery_results(results); + let tool_results = self.finish_tool_discovery_poll(tool_polled).await; + let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_results(results); + lock_tool_coordinator(&self.tool_coordinator).apply_discovery_results(tool_results); self.ensure_watch_roots().await; + let snapshot = self + .rebuild_product_snapshot_with_worker_recovery(command_snapshot, &recovery_targets) + .await; + let snapshot = snapshot?; let _ = self.updates.send(snapshot.clone()); + self.initial_refresh_completed + .store(true, Ordering::Release); + Ok(snapshot) + } + + async fn ensure_initial_refresh_with( + &self, + refresh: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + if self.initial_refresh_completed.load(Ordering::Acquire) { + return Ok(self.snapshot()); + } + let _initial_refresh_guard = self.initial_refresh_gate.lock().await; + if self.initial_refresh_completed.load(Ordering::Acquire) { + return Ok(self.snapshot()); + } + let snapshot = refresh().await?; + self.initial_refresh_completed + .store(true, Ordering::Release); + Ok(snapshot) + } + + async fn ensure_initial_refresh( + self: &Arc, + ) -> Result { + self.ensure_initial_refresh_with(|| self.refresh()).await + } + + async fn rebuild_product_snapshot( + &self, + command_snapshot: ExternalSourceCatalogSnapshot, + ) -> Result { + self.rebuild_product_snapshot_with_worker_recovery(command_snapshot, &BTreeSet::new()) + .await + } + + async fn rebuild_product_snapshot_with_worker_recovery( + &self, + _command_snapshot: ExternalSourceCatalogSnapshot, + worker_recovery_targets: &BTreeSet, + ) -> Result { + let _rebuild_guard = self.product_rebuild_gate.lock().await; + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + let preferences = read_external_sources_config().await?; + let mut state = reconcile_external_tools( + self.workspace_root.as_deref(), + "local-user", + &self.tool_coordinator, + ExternalToolDecisions { + approved_targets: &preferences.approved_tool_targets, + declined_decisions_by_approval: &preferences.declined_tool_decisions, + conflict_choices: &preferences.tool_conflict_choices, + }, + worker_recovery_targets, + ) + .await; + if let Err(error) = persist_observed_tool_conflicts(&state.conflicts).await { + state.diagnostics.push(ExternalSourceDiagnostic { + severity: bitfun_product_domains::external_sources::ExternalSourceDiagnosticSeverity::Warning, + code: "external_tool.conflict_history_write_failed".to_string(), + message: format!( + "Could not persist external tool conflict history; the current catalog remains fail-closed: {error}" + ), + source: None, + }); + } + let tool_snapshot = lock_tool_coordinator(&self.tool_coordinator).snapshot(); + let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, state); + let mut current = lock_snapshot(&self.snapshot); + snapshot.generation = snapshot + .generation + .max(current.generation.saturating_add(1)); + *current = snapshot.clone(); Ok(snapshot) } @@ -238,6 +424,84 @@ impl WorkspaceExternalSourceService { 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 task = spawn_tool_discovery_task(request); + tasks.insert( + provider_id.clone(), + InFlightToolDiscovery { + task: task.clone(), + wake_scheduled: false, + }, + ); + (provider_id, task, true) + }) + .collect() + } + + async fn finish_tool_discovery_poll( + 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 { + return; + }; + service + .complete_deferred_tool_discovery(provider_id, result) + .await; + }); + } + results + } + async fn complete_deferred_discovery( &self, provider_id: bitfun_product_domains::external_sources::ProviderId, @@ -253,16 +517,42 @@ impl WorkspaceExternalSourceService { { return; } - let snapshot = lock_coordinator(&self.coordinator).apply_discovery_result(result); + let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_result(result); self.ensure_watch_roots().await; - let _ = self.updates.send(snapshot); + if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { + let _ = self.updates.send(snapshot); + } } - fn ensure_background_refresh(self: &Arc) { + 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 - .initial_refresh_started - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_err() + .tool_discovery_tasks + .lock() + .await + .remove(&provider_id) + .is_none() + { + return; + } + lock_tool_coordinator(&self.tool_coordinator).apply_discovery_result(result); + self.ensure_watch_roots().await; + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { + let _ = self.updates.send(snapshot); + } + } + + fn ensure_background_refresh(self: &Arc) { + if self.initial_refresh_completed.load(Ordering::Acquire) + || self + .background_refresh_scheduled + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() { return; } @@ -271,9 +561,12 @@ impl WorkspaceExternalSourceService { let Some(service) = weak.upgrade() else { return; }; - if let Err(error) = service.refresh().await { + if let Err(error) = service.ensure_initial_refresh().await { log::warn!("Initial external source refresh failed: {}", error); } + service + .background_refresh_scheduled + .store(false, Ordering::Release); }); } @@ -303,6 +596,16 @@ impl WorkspaceExternalSourceService { if idle_for < IDLE_SECONDS || Arc::strong_count(&service) > 1 { continue; } + let _service_gate = workspace_service_gate().lock().await; + let idle_for = epoch_seconds() + .saturating_sub(service.last_access_epoch_seconds.load(Ordering::Acquire)); + if idle_for < IDLE_SECONDS || Arc::strong_count(&service) > 1 { + continue; + } + let _rebuild_guard = service.product_rebuild_gate.lock().await; + if Arc::strong_count(&service) > 1 { + continue; + } let key = service.workspace_root.clone(); if let Some(entry) = workspace_services().get(&key) { let should_remove = entry @@ -312,6 +615,7 @@ impl WorkspaceExternalSourceService { drop(entry); if should_remove { workspace_services().remove(&key); + release_external_tool_workspace(key.as_deref()).await; } } break; @@ -320,7 +624,7 @@ impl WorkspaceExternalSourceService { } fn snapshot(&self) -> ExternalSourceCatalogSnapshot { - lock_coordinator(&self.coordinator).snapshot() + lock_snapshot(&self.snapshot).clone() } async fn set_source_enabled( @@ -328,27 +632,36 @@ impl WorkspaceExternalSourceService { stable_key: &str, enabled: bool, ) -> Result { - let previous = { + let (previous_commands, command_known) = { let mut coordinator = lock_coordinator(&self.coordinator); let previous = coordinator.suppressed_sources().clone(); - coordinator.set_source_enabled(stable_key, enabled)?; - previous + 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 previous = coordinator.suppressed_sources().clone(); + let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); + (previous, known) }; - let updated = lock_coordinator(&self.coordinator) - .suppressed_sources() - .clone(); + if !command_known && !tool_known { + return Err(format!("unknown external source: {stable_key}")); + } let authoritative = match persist_source_enabled_change(stable_key, enabled).await { Ok(authoritative) => authoritative, Err(error) => { - lock_coordinator(&self.coordinator).replace_suppressed_sources(previous); + lock_coordinator(&self.coordinator).replace_suppressed_sources(previous_commands); + lock_tool_coordinator(&self.tool_coordinator) + .replace_suppressed_sources(previous_tools); return Err(error); } }; - if authoritative != updated { - log::debug!("External source suppression preferences changed in another workspace"); - } + lock_coordinator(&self.coordinator).replace_suppressed_sources(authoritative.clone()); + lock_tool_coordinator(&self.tool_coordinator) + .replace_suppressed_sources(authoritative.clone()); propagate_suppressed_sources(&authoritative); - Ok(self.snapshot()) + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await } async fn set_conflict_choice( @@ -408,7 +721,63 @@ impl WorkspaceExternalSourceService { log::debug!("External source conflict preferences changed in another workspace"); } propagate_conflict_preferences(&authoritative); - Ok(self.snapshot()) + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + + async fn set_tool_target_decision( + &self, + approval_key: &str, + decision_key: &str, + approved: bool, + ) -> Result { + // Keep preview validation, preference persistence and the resulting + // product rebuild in the same ordering domain as watcher refreshes. + // Otherwise an approval for content v1 could be persisted after a + // refresh installs v2 with the same capability-based approval key. + #[cfg(test)] + self.tool_decision_gate_waiting.notify_one(); + let _refresh_guard = self.refresh_gate.lock().await; + #[cfg(test)] + self.tool_decision_gate_acquired.notify_one(); + let snapshot = self.snapshot(); + let known = snapshot.tool_approval_requests.iter().any(|request| { + request.approval_key == approval_key && request.decision_key == decision_key + }) || snapshot + .tools + .iter() + .any(|tool| tool.approval_key == approval_key && tool.decision_key == decision_key); + if !known { + return Err("external tool decision is stale or unknown".to_string()); + } + validate_conflict_preference(approval_key, decision_key)?; + let preferences = + persist_tool_target_decision(approval_key, decision_key, approved).await?; + propagate_tool_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + + async fn set_tool_conflict_choice( + &self, + conflict_key: &str, + candidate_id: &str, + ) -> Result { + let known = self.snapshot().tool_conflicts.iter().any(|conflict| { + conflict.conflict_key == conflict_key + && conflict + .candidates + .iter() + .any(|candidate| candidate.candidate_id == candidate_id) + }); + if !known { + return Err("external tool conflict choice is stale or unknown".to_string()); + } + validate_conflict_preference(conflict_key, candidate_id)?; + let preferences = persist_tool_conflict_choice(conflict_key, candidate_id).await?; + propagate_tool_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await } async fn expand_command( @@ -420,7 +789,7 @@ impl WorkspaceExternalSourceService { ) -> Result { // Explicit invocation refreshes first, so a stable deletion cannot be // bypassed by an old menu projection. - self.refresh().await?; + self.refresh_preserving_worker_recovery().await?; let coordinator = Arc::clone(&self.coordinator); let name = name.to_string(); let arguments = arguments.to_string(); @@ -441,7 +810,7 @@ impl WorkspaceExternalSourceService { } async fn start_watching(self: &Arc) { - let watch_roots = lock_coordinator(&self.coordinator).watch_roots(); + let watch_roots = self.watch_roots(); if watch_roots.is_empty() { return; } @@ -464,7 +833,7 @@ impl WorkspaceExternalSourceService { let Some(service) = weak.upgrade() else { break; }; - let watch_roots = lock_coordinator(&service.coordinator).watch_roots(); + let watch_roots = service.watch_roots(); let relevant = events.iter().any(|event| { let path = Path::new(&event.path); watch_roots.iter().any(|root| path.starts_with(&root.path)) @@ -488,7 +857,7 @@ impl WorkspaceExternalSourceService { } async fn ensure_watch_roots(&self) { - let watch_roots = lock_coordinator(&self.coordinator).watch_roots(); + let watch_roots = self.watch_roots(); let watcher = Arc::clone(&self.watcher); let mut states = self.watch_states.lock().await; for root in watch_roots { @@ -518,6 +887,34 @@ impl WorkspaceExternalSourceService { } } } + + fn watch_roots(&self) -> Vec { + let mut roots = BTreeMap::new(); + for root in lock_coordinator(&self.coordinator) + .watch_roots() + .into_iter() + .chain(lock_tool_coordinator(&self.tool_coordinator).watch_roots()) + { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + if let Ok(store) = ExternalSourcePreferenceStore::global() { + if let Some(parent) = store.path.parent() { + roots.entry(parent.to_path_buf()).or_insert(false); + } + } + roots + .into_iter() + .map( + |(path, recursive)| bitfun_product_domains::external_sources::ExternalWatchRoot { + path, + recursive, + }, + ) + .collect() + } } enum DiscoveryPoll { @@ -526,6 +923,12 @@ enum DiscoveryPoll { 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), +} + async fn poll_discovery_tasks( scheduled: Vec<( bitfun_product_domains::external_sources::ProviderId, @@ -553,6 +956,33 @@ async fn poll_discovery_tasks( .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 +} + fn spawn_discovery_task(request: ExternalSourceDiscoveryRequest) -> SharedDiscoveryTask { let provider_id = request.provider_id().clone(); async move { @@ -569,6 +999,22 @@ fn spawn_discovery_task(request: ExternalSourceDiscoveryRequest) -> SharedDiscov .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 discovery_failure( provider_id: bitfun_product_domains::external_sources::ProviderId, code: &str, @@ -582,6 +1028,19 @@ fn discovery_failure( ) } +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 lock_coordinator( coordinator: &StdMutex, ) -> MutexGuard<'_, ExternalSourceCoordinator> { @@ -594,14 +1053,39 @@ fn lock_coordinator( } } +fn lock_tool_coordinator( + coordinator: &StdMutex, +) -> MutexGuard<'_, ExternalToolCoordinator> { + match coordinator.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn lock_snapshot( + snapshot: &StdMutex, +) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { + match snapshot.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + static WORKSPACE_SERVICES: OnceLock< DashMap, Weak>, > = OnceLock::new(); +static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); +static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); fn workspace_services() -> &'static DashMap, Weak> { WORKSPACE_SERVICES.get_or_init(DashMap::new) } +fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { + static GATE: OnceLock> = OnceLock::new(); + GATE.get_or_init(|| tokio::sync::Mutex::new(())) +} + fn normalize_workspace_root(workspace_root: Option<&Path>) -> Result, String> { let Some(workspace_root) = workspace_root else { return Ok(None); @@ -618,6 +1102,10 @@ async fn service_for( workspace_root: Option<&Path>, ) -> Result, String> { let workspace_root = normalize_workspace_root(workspace_root)?; + // Serialize cache acquisition with idle retirement. Without this lease + // gate, a caller could upgrade the weak entry after the retirement count + // check and have its newly acquired routes removed underneath it. + let _service_gate = workspace_service_gate().lock().await; if let Some(service) = workspace_services() .get(&workspace_root) .and_then(|service| service.value().upgrade()) @@ -657,6 +1145,96 @@ async fn read_external_sources_config() -> Result ExternalSourcePreferenceStore::global()?.read().await } +pub(crate) async fn external_tool_invocation_is_authorized( + approval_key: &str, + source_key: &str, +) -> Result { + let preferences = read_external_sources_config().await?; + Ok(external_tool_invocation_is_authorized_by( + &preferences, + approval_key, + source_key, + )) +} + +fn external_tool_invocation_is_authorized_by( + preferences: &ExternalSourcesConfig, + approval_key: &str, + source_preference_key: &str, +) -> bool { + preferences.approved_tool_targets.contains(approval_key) + && !preferences + .suppressed_source_keys + .iter() + .any(|suppressed| suppressed == source_preference_key) +} + +pub(crate) async fn external_tool_conflict_selection_is_current( + conflict_key: &str, + candidate_id: Option<&str>, +) -> Result { + let preferences = read_external_sources_config().await?; + let persisted = preferences + .tool_conflict_choices + .get(conflict_key) + .map(String::as_str) + .filter(|choice| { + *choice != UNRESOLVED_TOOL_CONFLICT_CHOICE + && *choice != TOOL_CONFLICT_RESELECTION_REQUIRED + }); + Ok(persisted == candidate_id) +} + +async fn persist_observed_tool_conflicts(conflicts: &[ExternalToolConflict]) -> Result<(), String> { + if conflicts.is_empty() { + return Ok(()); + } + let conflicts = conflicts.to_vec(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + for conflict in conflicts { + reconcile_observed_tool_conflict( + &mut config.tool_conflict_choices, + &conflict.conflict_key, + ); + } + }) + .await + .map(|_| ()) +} + +fn reconcile_observed_tool_conflict(choices: &mut BTreeMap, conflict_key: &str) { + if choices.contains_key(conflict_key) { + return; + } + let Some((lineage, _)) = conflict_key.rsplit_once(':') else { + choices.insert( + conflict_key.to_string(), + UNRESOLVED_TOOL_CONFLICT_CHOICE.to_string(), + ); + return; + }; + let requires_fail_closed_reselection = choices.iter().any(|(existing_key, choice)| { + existing_key + .rsplit_once(':') + .is_some_and(|(existing_lineage, _)| existing_lineage == lineage) + && (choice.starts_with("external:") || choice == TOOL_CONFLICT_RESELECTION_REQUIRED) + }); + choices.retain(|existing_key, _| { + existing_key + .rsplit_once(':') + .is_none_or(|(existing_lineage, _)| existing_lineage != lineage) + }); + choices.insert( + conflict_key.to_string(), + if requires_fail_closed_reselection { + TOOL_CONFLICT_RESELECTION_REQUIRED.to_string() + } else { + UNRESOLVED_TOOL_CONFLICT_CHOICE.to_string() + }, + ); +} + async fn persist_source_enabled_change( stable_key: &str, enabled: bool, @@ -703,17 +1281,85 @@ async fn persist_conflict_choice( .map(|(_, config)| config) } +async fn persist_tool_target_decision( + approval_key: &str, + decision_key: &str, + approved: bool, +) -> Result { + let approval_key = approval_key.to_string(); + let decision_key = decision_key.to_string(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + reconcile_tool_target_decision(config, approval_key, decision_key, approved); + }) + .await + .map(|(_, config)| config) +} + +fn reconcile_tool_target_decision( + config: &mut ExternalSourcesConfig, + approval_key: String, + decision_key: String, + approved: bool, +) { + if approved { + config.approved_tool_targets.insert(approval_key.clone()); + config.declined_tool_decisions.remove(&approval_key); + } else { + config.approved_tool_targets.remove(&approval_key); + config + .declined_tool_decisions + .insert(approval_key, decision_key); + } +} + +async fn persist_tool_conflict_choice( + conflict_key: &str, + candidate_id: &str, +) -> Result { + let conflict_key = conflict_key.to_string(); + let candidate_id = candidate_id.to_string(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + reconcile_versioned_tool_conflict_choice( + &mut config.tool_conflict_choices, + conflict_key, + candidate_id, + ); + }) + .await + .map(|(_, config)| config) +} + +fn reconcile_versioned_tool_conflict_choice( + choices: &mut BTreeMap, + conflict_key: String, + candidate_id: String, +) { + if let Some((lineage, _)) = conflict_key.rsplit_once(':') { + choices.retain(|existing_key, _| { + existing_key + .rsplit_once(':') + .is_none_or(|(existing_lineage, _)| existing_lineage != lineage) + }); + } + choices.insert(conflict_key, candidate_id); +} + fn propagate_suppressed_sources(sources: &BTreeSet) { for service in workspace_services().iter() { let Some(service) = service.value().upgrade() else { continue; }; - let snapshot = { - let mut coordinator = lock_coordinator(&service.coordinator); - coordinator.replace_suppressed_sources(sources.clone()); - coordinator.snapshot() - }; - let _ = service.updates.send(snapshot); + lock_coordinator(&service.coordinator).replace_suppressed_sources(sources.clone()); + lock_tool_coordinator(&service.tool_coordinator) + .replace_suppressed_sources(sources.clone()); + tokio::spawn(async move { + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { + let _ = service.updates.send(snapshot); + } + }); } } @@ -722,7 +1368,7 @@ fn propagate_conflict_preferences(preferences: &ExternalSourcesConfig) { let Some(service) = service.value().upgrade() else { continue; }; - let snapshot = { + { let mut coordinator = lock_coordinator(&service.coordinator); coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); coordinator.replace_conflict_lineage_current_keys( @@ -730,12 +1376,66 @@ fn propagate_conflict_preferences(preferences: &ExternalSourcesConfig) { ); coordinator .replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids.clone()); - coordinator.snapshot() + } + tokio::spawn(async move { + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { + let _ = service.updates.send(snapshot); + } + }); + } +} + +fn propagate_tool_preferences(_preferences: &ExternalSourcesConfig) { + for service in workspace_services().iter() { + let Some(service) = service.value().upgrade() else { + continue; }; - let _ = service.updates.send(snapshot); + tokio::spawn(async move { + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { + let _ = service.updates.send(snapshot); + } + }); } } +pub(crate) fn notify_external_tool_registry_changed() { + TOOL_REGISTRY_CHANGE_EPOCH.fetch_add(1, Ordering::AcqRel); + if TOOL_REGISTRY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) { + return; + } + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + TOOL_REGISTRY_REBUILD_SCHEDULED.store(false, Ordering::Release); + return; + }; + runtime.spawn(async move { + loop { + let observed_epoch = TOOL_REGISTRY_CHANGE_EPOCH.load(Ordering::Acquire); + let services = workspace_services() + .iter() + .filter_map(|entry| entry.value().upgrade()) + .collect::>(); + for service in services { + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { + let _ = service.updates.send(snapshot); + } + } + if TOOL_REGISTRY_CHANGE_EPOCH.load(Ordering::Acquire) != observed_epoch { + continue; + } + TOOL_REGISTRY_REBUILD_SCHEDULED.store(false, Ordering::Release); + if TOOL_REGISTRY_CHANGE_EPOCH.load(Ordering::Acquire) == observed_epoch { + break; + } + if TOOL_REGISTRY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) { + break; + } + } + }); +} + async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> Result<(), String> { let preferences = read_external_sources_config().await?; let suppressed_sources = preferences @@ -743,11 +1443,11 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R .iter() .cloned() .collect::>(); - let (changed, snapshot) = { + let command_changed = { let mut coordinator = lock_coordinator(&service.coordinator); let mut changed = false; if coordinator.suppressed_sources() != &suppressed_sources { - coordinator.replace_suppressed_sources(suppressed_sources); + coordinator.replace_suppressed_sources(suppressed_sources.clone()); changed = true; } if coordinator.conflict_choices() != &preferences.conflict_choices { @@ -756,17 +1456,30 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R } if coordinator.conflict_lineage_current_keys() != &preferences.conflict_lineage_current_keys { - coordinator - .replace_conflict_lineage_current_keys(preferences.conflict_lineage_current_keys); + coordinator.replace_conflict_lineage_current_keys( + preferences.conflict_lineage_current_keys.clone(), + ); changed = true; } if coordinator.conflicted_candidate_ids() != &preferences.conflicted_candidate_ids { - coordinator.replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids); + coordinator + .replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids.clone()); changed = true; } - (changed, coordinator.snapshot()) + changed + }; + let tool_changed = { + let mut coordinator = lock_tool_coordinator(&service.tool_coordinator); + if coordinator.suppressed_sources() != &suppressed_sources { + coordinator.replace_suppressed_sources(suppressed_sources); + true + } else { + false + } }; - if changed { + if command_changed || tool_changed { + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let snapshot = service.rebuild_product_snapshot(command_snapshot).await?; let _ = service.updates.send(snapshot); } Ok(()) @@ -842,6 +1555,29 @@ pub async fn set_external_prompt_command_conflict_choice( .await } +pub async fn set_external_tool_target_decision( + workspace_root: Option<&Path>, + approval_key: &str, + decision_key: &str, + approved: bool, +) -> Result { + service_for(workspace_root) + .await? + .set_tool_target_decision(approval_key, decision_key, approved) + .await +} + +pub async fn set_external_tool_conflict_choice( + workspace_root: Option<&Path>, + conflict_key: &str, + candidate_id: &str, +) -> Result { + service_for(workspace_root) + .await? + .set_tool_conflict_choice(conflict_key, candidate_id) + .await +} + pub async fn external_source_snapshot( workspace_root: Option<&Path>, force_refresh: bool, @@ -855,6 +1591,30 @@ pub async fn external_source_snapshot( } } +/// Keep the external-source runtime aligned with an actively assembled product +/// tool catalog. A newly created service performs one synchronous refresh so an +/// idle-retired workspace can restore approved routes before the catalog is +/// exposed to the model. Existing services are only touched; file watchers and +/// explicit refreshes remain responsible for later source changes. +pub(crate) async fn ensure_external_source_workspace_runtime(workspace_root: Option<&Path>) { + let service = match service_for(workspace_root).await { + Ok(service) => service, + Err(error) => { + log::warn!("Could not retain external source workspace runtime: {error}"); + return; + } + }; + if let Err(error) = service.ensure_initial_refresh().await { + log::warn!("Could not initialize external source workspace runtime: {error}"); + return; + } + if external_tool_workspace_requires_recovery(workspace_root).await { + if let Err(error) = service.refresh_worker_loss_once().await { + log::warn!("Could not recover external source tool runtime: {error}"); + } + } +} + pub async fn set_external_source_enabled( workspace_root: Option<&Path>, source_key: &str, @@ -1010,19 +1770,32 @@ 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, Vec::new()).unwrap(); + let snapshot = merge_tool_state( + coordinator.snapshot(), + &tool_coordinator.snapshot(), + ExternalToolProductState::default(), + ); Arc::new(WorkspaceExternalSourceService { workspace_root: None, - coordinator: Arc::new(StdMutex::new( - ExternalSourceCoordinator::new(context, providers).unwrap(), - )), + coordinator: Arc::new(StdMutex::new(coordinator)), + tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), + 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()), - initial_refresh_started: AtomicBool::new(false), + tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + initial_refresh_completed: AtomicBool::new(false), + background_refresh_scheduled: AtomicBool::new(false), + initial_refresh_gate: tokio::sync::Mutex::new(()), keepalive_started: AtomicBool::new(false), last_access_epoch_seconds: AtomicU64::new(epoch_seconds()), watcher: Arc::new(FileWatchService::new(FileWatcherConfig::default())), + tool_decision_gate_waiting: tokio::sync::Notify::new(), + tool_decision_gate_acquired: tokio::sync::Notify::new(), }) } @@ -1085,6 +1858,68 @@ mod tests { assert!(error.contains("deserialize")); } + #[test] + fn invocation_authorization_uses_the_execution_domain_preference_key() { + let source = ExternalSourceRecord { + key: SourceKey::new("opencode", "global-tools").unwrap(), + ecosystem_id: bitfun_product_domains::external_sources::EcosystemId::new("opencode") + .unwrap(), + display_name: "OpenCode tools".to_string(), + source_kind: "standalone_tools".to_string(), + scope: ExternalSourceScope::UserGlobal, + location: "/tools".to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Available, + content_version: "v1".to_string(), + diagnostics: Vec::new(), + }; + let approval_key = "approval"; + let mut config = ExternalSourcesConfig { + approved_tool_targets: BTreeSet::from([approval_key.to_string()]), + ..ExternalSourcesConfig::default() + }; + + config.suppressed_source_keys.push(source.preference_key()); + assert!(!external_tool_invocation_is_authorized_by( + &config, + approval_key, + &source.preference_key() + )); + assert!(external_tool_invocation_is_authorized_by( + &config, + approval_key, + &source.key.stable_key() + )); + } + + #[test] + fn observed_tool_conflict_requires_reselection_after_external_lineage_changes() { + let old = "external_tool:domain:read:old"; + let current = "external_tool:domain:read:new"; + let mut choices = BTreeMap::from([(old.to_string(), "external:source-a".to_string())]); + + reconcile_observed_tool_conflict(&mut choices, current); + + assert!(!choices.contains_key(old)); + assert_eq!( + choices.get(current).map(String::as_str), + Some(TOOL_CONFLICT_RESELECTION_REQUIRED) + ); + } + + #[test] + fn first_observed_tool_conflict_persists_an_unresolved_lineage() { + let conflict_key = "external_tool:domain:read:first"; + let mut choices = BTreeMap::new(); + + reconcile_observed_tool_conflict(&mut choices, conflict_key); + + assert_eq!( + choices.get(conflict_key).map(String::as_str), + Some(UNRESOLVED_TOOL_CONFLICT_CHOICE) + ); + } + #[test] fn conflict_lineages_are_compact_and_independent() { let mut choices = BTreeMap::from([ @@ -1128,6 +1963,130 @@ mod tests { assert_eq!(lineage_keys.len(), 2); } + #[test] + fn tool_decisions_keep_only_the_current_decline_per_approval() { + let mut config = ExternalSourcesConfig::default(); + + reconcile_tool_target_decision( + &mut config, + "approval-a".to_string(), + "decision-v1".to_string(), + false, + ); + reconcile_tool_target_decision( + &mut config, + "approval-a".to_string(), + "decision-v2".to_string(), + false, + ); + + assert_eq!( + config.declined_tool_decisions, + BTreeMap::from([("approval-a".to_string(), "decision-v2".to_string())]) + ); + reconcile_tool_target_decision( + &mut config, + "approval-a".to_string(), + "decision-v2".to_string(), + true, + ); + assert!(config.declined_tool_decisions.is_empty()); + assert_eq!( + config.approved_tool_targets, + BTreeSet::from(["approval-a".to_string()]) + ); + } + + #[tokio::test] + async fn tool_approval_waits_for_refresh_and_rejects_a_changed_decision() { + let service = test_service(Vec::new()); + let request = |decision_key: &str, content_version: &str| { + serde_json::from_value::(serde_json::json!({ + "approvalKey": "approval-a", + "decisionKey": decision_key, + "targetId": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "review.js" + }, + "sourceDisplayName": "OpenCode project tools", + "sourceScope": "project", + "sourceLocation": "/repo/.opencode/tools/review.js", + "workingDirectory": "/repo", + "runtimeKind": "java_script", + "capabilities": ["file_system"], + "contentVersion": content_version, + "toolNames": ["review"] + })) + .unwrap() + }; + lock_snapshot(&service.snapshot).tool_approval_requests = + vec![request("decision-v1", "v1")]; + + let refresh_guard = service.refresh_gate.lock().await; + let decision_service = Arc::clone(&service); + let decision = tokio::spawn(async move { + decision_service + .set_tool_target_decision("approval-a", "decision-v1", true) + .await + }); + tokio::time::timeout( + std::time::Duration::from_secs(1), + service.tool_decision_gate_waiting.notified(), + ) + .await + .expect("approval task must reach the refresh gate"); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(50), + service.tool_decision_gate_acquired.notified(), + ) + .await + .is_err(), + "approval must not enter the decision critical section while refresh owns the gate" + ); + + lock_snapshot(&service.snapshot).tool_approval_requests = + vec![request("decision-v2", "v2")]; + drop(refresh_guard); + tokio::time::timeout( + std::time::Duration::from_secs(1), + service.tool_decision_gate_acquired.notified(), + ) + .await + .expect("approval task must enter after the refresh releases the gate"); + + let error = decision + .await + .unwrap() + .expect_err("the approval must not apply to the changed content"); + assert_eq!(error, "external tool decision is stale or unknown"); + } + + #[test] + fn tool_conflict_choices_keep_only_the_current_version_per_lineage() { + let mut choices = BTreeMap::from([ + ( + "external_tool:local-user:review:old".to_string(), + "external-a".to_string(), + ), + ( + "external_tool:local-user:help:old".to_string(), + "builtin-help".to_string(), + ), + ]); + + reconcile_versioned_tool_conflict_choice( + &mut choices, + "external_tool:local-user:review:new".to_string(), + "external-b".to_string(), + ); + + assert!(!choices.contains_key("external_tool:local-user:review:old")); + assert_eq!(choices["external_tool:local-user:review:new"], "external-b"); + assert_eq!(choices["external_tool:local-user:help:old"], "builtin-help"); + assert_eq!(choices.len(), 2); + } + #[tokio::test] async fn slow_provider_is_not_respawned_while_healthy_sibling_updates() { let slow_calls = Arc::new(AtomicUsize::new(0)); @@ -1171,4 +2130,75 @@ mod tests { .iter() .any(|command| command.definition.name == "healthy")); } + + #[tokio::test] + async fn initial_refresh_waiters_reuse_the_in_flight_result() { + let service = test_service(Vec::new()); + let calls = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + + let background = { + let service = Arc::clone(&service); + let snapshot_service = Arc::clone(&service); + let calls = Arc::clone(&calls); + let started = Arc::clone(&started); + let release = Arc::clone(&release); + tokio::spawn(async move { + service + .ensure_initial_refresh_with(|| async move { + calls.fetch_add(1, Ordering::SeqCst); + started.notify_one(); + release.notified().await; + Ok(snapshot_service.snapshot()) + }) + .await + }) + }; + + started.notified().await; + let catalog_waiter = { + let service = Arc::clone(&service); + let snapshot_service = Arc::clone(&service); + let calls = Arc::clone(&calls); + tokio::spawn(async move { + service + .ensure_initial_refresh_with(|| async move { + calls.fetch_add(100, Ordering::SeqCst); + Ok(snapshot_service.snapshot()) + }) + .await + }) + }; + tokio::task::yield_now().await; + assert!(!catalog_waiter.is_finished()); + + release.notify_one(); + background.await.unwrap().unwrap(); + catalog_waiter.await.unwrap().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn failed_initial_refresh_can_be_retried() { + let service = test_service(Vec::new()); + let first = service + .ensure_initial_refresh_with(|| async { Err("temporary failure".to_string()) }) + .await; + assert_eq!(first.unwrap_err(), "temporary failure"); + + let calls = Arc::new(AtomicUsize::new(0)); + let snapshot_service = Arc::clone(&service); + service + .ensure_initial_refresh_with(|| { + let calls = Arc::clone(&calls); + async move { + calls.fetch_add(1, Ordering::SeqCst); + Ok(snapshot_service.snapshot()) + } + }) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } } diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs new file mode 100644 index 0000000000..949fcb16a7 --- /dev/null +++ b/src/crates/assembly/core/src/external_tools.rs @@ -0,0 +1,2530 @@ +//! Product-owned activation and contextual routing for external standalone tools. + +use crate::agentic::tools::framework::{ + DynamicToolInfo, Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, +}; +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_product_domains::external_sources::{ + external_tool_approval_key, external_tool_conflict_key, external_tool_decision_key, + ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceScope, + ExternalToolActivationState, ExternalToolApprovalRequest, ExternalToolCatalogEntry, + ExternalToolConflict, ExternalToolConflictCandidate, ExternalToolConflictCandidateKind, + ExternalToolDefinition, ExternalToolStaticStatus, PreparedExternalToolTarget, + SourceQualifiedToolTargetId, +}; +use bitfun_runtime_ports::{ + PortErrorKind, ScriptToolDescriptor, ScriptToolExpectedExport, ScriptToolInvokeRequest, + ScriptToolLoadRequest, ScriptToolRuntime, ScriptToolRuntimeAvailability, +}; +use bitfun_services_integrations::script_tool::NodeScriptToolRuntime; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock as StdRwLock}; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Default)] +pub(super) struct ExternalToolProductState { + pub tools: Vec, + pub approval_requests: Vec, + pub conflicts: Vec, + pub diagnostics: Vec, +} + +pub(super) struct ExternalToolDecisions<'a> { + pub approved_targets: &'a BTreeSet, + pub declined_decisions_by_approval: &'a BTreeMap, + pub conflict_choices: &'a BTreeMap, +} + +pub(super) const UNRESOLVED_TOOL_CONFLICT_CHOICE: &str = "__bitfun_unresolved__"; +pub(super) const TOOL_CONFLICT_RESELECTION_REQUIRED: &str = "__bitfun_reselection_required__"; + +#[derive(Clone)] +struct LoadedExternalTool { + descriptor: ScriptToolDescriptor, + provider_id: String, + runtime_target_id: String, + load_generation: u64, + revision: String, + approval_key: String, + source_preference_key: String, + workspace_key: String, + target_tool_names: Arc>, + worktree_root: Option, + runtime: Arc, +} + +#[async_trait] +impl Tool for LoadedExternalTool { + fn name(&self) -> &str { + &self.descriptor.name + } + + async fn description(&self) -> BitFunResult { + Ok(self.descriptor.description.clone()) + } + + fn short_description(&self) -> String { + self.descriptor.description.clone() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Direct + } + + fn input_schema(&self) -> Value { + self.descriptor.input_schema.clone() + } + + fn dynamic_provider_id(&self) -> Option<&str> { + Some(&self.provider_id) + } + + fn dynamic_tool_info(&self) -> Option { + Some(DynamicToolInfo { + provider_id: self.provider_id.clone(), + provider_kind: Some("external_source".to_string()), + mcp: None, + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + true + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + if !crate::external_sources::external_tool_invocation_is_authorized( + &self.approval_key, + &self.source_preference_key, + ) + .await + .map_err(BitFunError::tool)? + { + return Err(BitFunError::tool(format!( + "external tool '{}' was disabled in another BitFun process; refresh external tools before retrying", + self.name() + ))); + } + static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1); + let operation_id = format!( + "external-tool-operation-{}", + NEXT_OPERATION.fetch_add(1, Ordering::Relaxed) + ); + let request = ScriptToolInvokeRequest { + target_id: self.runtime_target_id.clone(), + revision: self.revision.clone(), + export_name: self.descriptor.export_name.clone(), + operation_id: operation_id.clone(), + arguments: input.clone(), + workspace_root: context + .workspace_root() + .map(|path| path.to_string_lossy().into_owned()), + worktree_root: self.worktree_root.clone(), + session_id: context.session_id.clone(), + }; + let mut invocation = Box::pin(self.runtime.invoke(request)); + let response = if let Some(cancellation) = context.cancellation_token() { + tokio::select! { + result = &mut invocation => result, + _ = cancellation.cancelled() => { + let cancel_result = self.runtime.cancel(&self.runtime_target_id, &operation_id).await; + // The Node cancel acknowledgement is sent only after the + // operation leaves the worker's active set. Drain the + // pinned invocation so its cancellation-safe drop guard + // is disarmed instead of racing a second cancellation. + let _ = invocation.await; + if !self.runtime.is_loaded(&self.runtime_target_id).await { + report_external_tool_worker_lost( + &self.workspace_key, + &self.runtime_target_id, + self.load_generation, + self.target_tool_names.as_ref(), + cancel_result + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| "tool process stopped while cancelling the invocation".to_string()), + ) + .await; + } + return Err(BitFunError::Cancelled(format!("external tool '{}' was cancelled", self.name()))); + } + } + } else { + invocation.await + }; + let response = match response { + Ok(response) => response, + Err(error) => { + if matches!( + error.kind, + PortErrorKind::Timeout | PortErrorKind::NotAvailable | PortErrorKind::NotFound + ) && !self.runtime.is_loaded(&self.runtime_target_id).await + { + report_external_tool_worker_lost( + &self.workspace_key, + &self.runtime_target_id, + self.load_generation, + self.target_tool_names.as_ref(), + error.to_string(), + ) + .await; + } + return Err(BitFunError::tool(error.to_string())); + } + }; + Ok(vec![ToolResult::ok( + Value::String(response.output.clone()), + Some(response.output), + )]) + } +} + +#[derive(Clone)] +struct ConflictExpectation { + key: String, + selected_candidate_id: Option, +} + +#[derive(Clone)] +enum WorkspaceRoute { + Original { + conflict: Option, + }, + External { + tool: Arc, + conflict: Option, + }, + Unavailable { + conflict: Option, + }, +} + +impl WorkspaceRoute { + fn conflict(&self) -> Option<&ConflictExpectation> { + match self { + Self::Original { conflict } + | Self::External { conflict, .. } + | Self::Unavailable { conflict } => conflict.as_ref(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ConflictRouteChoice { + Original, + External(String), + Unavailable, +} + +fn resolve_conflict_route_choice( + conflict_key: &str, + candidates: &[ExternalToolConflictCandidate], + external_candidate_ids: &BTreeSet, + has_original: bool, + choices: &BTreeMap, +) -> (Option, ConflictRouteChoice) { + let selected = choices + .get(conflict_key) + .filter(|selected| { + candidates + .iter() + .any(|candidate| &candidate.candidate_id == *selected) + }) + .cloned(); + let prior_requires_fail_closed = choices.iter().any(|(key, choice)| { + tool_conflict_lineage(key) == tool_conflict_lineage(conflict_key) + && (choice.starts_with("external:") || choice == TOOL_CONFLICT_RESELECTION_REQUIRED) + }); + let route = match selected.as_deref() { + Some(selected) if external_candidate_ids.contains(selected) => { + ConflictRouteChoice::External(selected.to_string()) + } + Some(_) if has_original => ConflictRouteChoice::Original, + None if prior_requires_fail_closed => ConflictRouteChoice::Unavailable, + None if has_original => ConflictRouteChoice::Original, + _ => ConflictRouteChoice::Unavailable, + }; + (selected, route) +} + +fn tool_conflict_lineage(conflict_key: &str) -> &str { + conflict_key + .rsplit_once(':') + .map_or(conflict_key, |(lineage, _)| lineage) +} + +fn tool_conflict_lineage_for_name(execution_domain_id: &str, tool_name: &str) -> String { + format!("external_tool:{}:{}", execution_domain_id, tool_name) +} + +fn has_tool_conflict_history( + choices: &BTreeMap, + conflict_domain: &str, + tool_name: &str, +) -> bool { + let lineage = tool_conflict_lineage_for_name(conflict_domain, tool_name); + choices + .keys() + .any(|conflict_key| tool_conflict_lineage(conflict_key) == lineage) +} + +fn tool_conflict_history_requires_fail_closed( + choices: &BTreeMap, + conflict_domain: &str, + tool_name: &str, +) -> bool { + let lineage = tool_conflict_lineage_for_name(conflict_domain, tool_name); + choices.iter().any(|(conflict_key, choice)| { + tool_conflict_lineage(conflict_key) == lineage + && (choice.starts_with("external:") || choice == TOOL_CONFLICT_RESELECTION_REQUIRED) + }) +} + +fn retain_fail_closed_routes_during_reconcile( + routes: &mut BTreeMap, + discovered_names: &BTreeSet, +) { + routes.retain(|name, route| { + discovered_names.contains(name) + || matches!( + route, + WorkspaceRoute::External { + conflict: Some(_), + .. + } | WorkspaceRoute::Unavailable { conflict: Some(_) } + ) + }); + for (name, route) in routes { + if discovered_names.contains(name) { + continue; + } + if let WorkspaceRoute::External { conflict, .. } = route { + *route = WorkspaceRoute::Unavailable { + conflict: conflict.clone(), + }; + } + } +} + +struct ExternalToolMux { + name: String, + original: StdRwLock>>, + routes: StdRwLock>, +} + +impl ExternalToolMux { + fn new(name: String, original: Option>) -> Self { + Self { + name, + original: StdRwLock::new(original), + routes: StdRwLock::new(HashMap::new()), + } + } + + fn original(&self) -> Option> { + self.original + .read() + .expect("external tool original lock poisoned") + .clone() + } + + fn replace_original(&self, original: Option>) { + *self + .original + .write() + .expect("external tool original lock poisoned") = original; + } + + fn set_route(&self, workspace_key: String, route: WorkspaceRoute) { + self.routes + .write() + .expect("external tool route lock poisoned") + .insert(workspace_key, route); + } + + fn remove_route(&self, workspace_key: &str) { + self.routes + .write() + .expect("external tool route lock poisoned") + .remove(workspace_key); + } + + fn route_count(&self) -> usize { + self.routes + .read() + .expect("external tool route lock poisoned") + .len() + } + + fn selected(&self, context: Option<&ToolUseContext>) -> Option> { + if context.is_some_and(ToolUseContext::is_remote) { + // Remote external-source ownership is not implemented. Never use + // a local route solely because the remote path text matches. + return self.original(); + } + self.selected_for_workspace(context.and_then(ToolUseContext::workspace_root)) + } + + fn selected_for_workspace(&self, workspace_root: Option<&Path>) -> Option> { + let workspace_key = workspace_route_key(workspace_root); + match self + .routes + .read() + .expect("external tool route lock poisoned") + .get(&workspace_key) + .cloned() + { + Some(WorkspaceRoute::External { tool, .. }) => Some(tool), + Some(WorkspaceRoute::Unavailable { .. }) => None, + Some(WorkspaceRoute::Original { .. }) | None => self.original(), + } + } +} + +#[async_trait] +impl Tool for ExternalToolMux { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> BitFunResult { + match self.original() { + Some(tool) => tool.description().await, + None => Ok(format!("External tool: {}", self.name)), + } + } + + async fn description_with_context( + &self, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + match self.selected(context) { + Some(tool) => tool.description_with_context(context).await, + None => Err(BitFunError::tool(format!( + "tool '{}' is waiting for an external-source decision", + self.name + ))), + } + } + + fn short_description(&self) -> String { + self.original() + .map(|tool| tool.short_description()) + .unwrap_or_else(|| format!("External tool: {}", self.name)) + } + + fn default_exposure(&self) -> ToolExposure { + self.original() + .map(|tool| tool.default_exposure()) + .unwrap_or(ToolExposure::Direct) + } + + fn input_schema(&self) -> Value { + self.original() + .map(|tool| tool.input_schema()) + .unwrap_or_else(|| serde_json::json!({ "type": "object" })) + } + + async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { + match self.selected(context) { + Some(tool) => tool.input_schema_for_model_with_context(context).await, + None => self.input_schema(), + } + } + + fn dynamic_provider_id(&self) -> Option<&str> { + Some("external-source-router") + } + + fn dynamic_tool_info(&self) -> Option { + self.original() + .and_then(|tool| tool.dynamic_tool_info()) + .or_else(|| { + Some(DynamicToolInfo { + provider_id: "external-source-router".to_string(), + provider_kind: Some("contextual_router".to_string()), + mcp: None, + }) + }) + } + + async fn is_enabled(&self) -> bool { + self.original().is_some() || self.route_count() > 0 + } + + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + self.selected(context).is_some() + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + true + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + match self.selected(context) { + Some(tool) => tool.validate_input(input, context).await, + None => ValidationResult { + result: false, + message: Some(format!( + "tool '{}' is waiting for an external-source decision", + self.name + )), + error_code: None, + meta: None, + }, + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let selected = self.selected(Some(context)).ok_or_else(|| { + BitFunError::tool(format!( + "tool '{}' is waiting for an external-source decision", + self.name + )) + })?; + selected.call(input, context).await + } + + async fn call(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { + if context.is_remote() { + return self + .original() + .ok_or_else(|| { + BitFunError::tool(format!( + "tool '{}' is unavailable in remote workspaces", + self.name + )) + })? + .call(input, context) + .await; + } + let workspace_key = workspace_route_key(context.workspace_root()); + let route = self + .routes + .read() + .expect("external tool route lock poisoned") + .get(&workspace_key) + .cloned(); + if let Some(expectation) = route.as_ref().and_then(WorkspaceRoute::conflict) { + if !crate::external_sources::external_tool_conflict_selection_is_current( + &expectation.key, + expectation.selected_candidate_id.as_deref(), + ) + .await + .map_err(BitFunError::tool)? + { + return Err(BitFunError::tool(format!( + "tool conflict choice for '{}' changed in another BitFun process; refresh before retrying", + self.name + ))); + } + } + match route { + Some(WorkspaceRoute::External { tool, .. }) => tool.call(input, context).await, + Some(WorkspaceRoute::Original { .. }) | None => { + self.original() + .ok_or_else(|| { + BitFunError::tool(format!( + "tool '{}' is waiting for an external-source decision", + self.name + )) + })? + .call(input, context) + .await + } + Some(WorkspaceRoute::Unavailable { .. }) => Err(BitFunError::tool(format!( + "tool '{}' is waiting for an external-source decision", + self.name + ))), + } + } +} + +struct ExternalToolRouter { + muxes: StdMutex>>, + mutation_gate: Mutex<()>, +} + +impl Default for ExternalToolRouter { + fn default() -> Self { + Self { + muxes: StdMutex::new(BTreeMap::new()), + mutation_gate: Mutex::new(()), + } + } +} + +impl ExternalToolRouter { + fn known_name(&self, tool_name: &str) -> Option { + self.muxes + .lock() + .expect("external tool router lock poisoned") + .keys() + .find(|name| name.as_str() == tool_name) + .cloned() + } + + async fn original_tool(&self, name: &str) -> Option> { + if let Some(mux) = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .get(name) + { + return mux.original(); + } + get_global_tool_registry().read().await.get_tool(name) + } + + async fn apply_routes(&self, workspace_key: &str, routes: BTreeMap) { + let _mutation_guard = self.mutation_gate.lock().await; + let current_muxes = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .values() + .cloned() + .collect::>(); + for mux in current_muxes { + if !routes.contains_key(&mux.name) { + mux.remove_route(workspace_key); + } + } + for (name, route) in routes { + let existing = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .get(&name) + .cloned(); + let mux = if let Some(mux) = existing { + mux + } else { + let registry = get_global_tool_registry(); + let mut registry = registry.write().await; + let original = registry.unregister_tool(&name); + let mux = Arc::new(ExternalToolMux::new(name.clone(), original)); + self.muxes + .lock() + .expect("external tool router lock poisoned") + .insert(name.clone(), mux.clone()); + registry.register_tool(mux.clone()); + mux + }; + mux.set_route(workspace_key.to_string(), route); + } + + // Keep zero-route muxes installed as the stable interception point for future + // registry registrations. Uninstalling a mux would require an atomic swap across + // the router and registry, which their independent locks cannot provide. + } + + async fn withdraw_failed_target( + &self, + workspace_key: &str, + runtime_target_id: &str, + load_generation: u64, + tool_names: &[String], + ) { + let _mutation_guard = self.mutation_gate.lock().await; + for name in tool_names { + let mux = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .get(name) + .cloned(); + let Some(mux) = mux else { + continue; + }; + let mut routes = mux + .routes + .write() + .expect("external tool route lock poisoned"); + let Some(WorkspaceRoute::External { tool, conflict }) = routes.get(workspace_key) + else { + continue; + }; + if tool.runtime_target_id != runtime_target_id + || tool.load_generation != load_generation + { + continue; + } + // Never silently fall back to a built-in/MCP implementation after + // the user explicitly selected an external conflict candidate. + let conflict = conflict.clone(); + routes.insert( + workspace_key.to_string(), + WorkspaceRoute::Unavailable { conflict }, + ); + } + } + + fn intercept_registration(&self, tool: Arc) -> Arc { + let mux = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .get(tool.name()) + .cloned(); + let Some(mux) = mux else { + return tool; + }; + let routed: Arc = mux.clone(); + if Arc::ptr_eq(&routed, &tool) { + return tool; + } + mux.replace_original(Some(tool)); + routed + } + + fn detach_mcp_server(&self, server_id: &str) -> Vec> { + let muxes = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .values() + .cloned() + .collect::>(); + let mut retained_muxes = Vec::new(); + for mux in muxes { + let matches_server = mux + .original() + .and_then(|tool| tool.dynamic_tool_info()) + .and_then(|info| info.mcp) + .is_some_and(|mcp| mcp.server_id == server_id); + if matches_server { + mux.replace_original(None); + let routed: Arc = mux; + retained_muxes.push(routed); + } + } + retained_muxes + } + + fn retain_muxes_for_prefix(&self, prefix: &str) -> Vec> { + self.muxes + .lock() + .expect("external tool router lock poisoned") + .iter() + .filter(|(name, _)| name.starts_with(prefix)) + .map(|(_, mux)| { + mux.replace_original(None); + let routed: Arc = mux.clone(); + routed + }) + .collect() + } + + fn resolve_registered_tool( + &self, + tool: Arc, + workspace_root: Option<&Path>, + ) -> Option> { + let mux = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .get(tool.name()) + .cloned(); + match mux { + Some(mux) => mux.selected_for_workspace(workspace_root), + None => Some(tool), + } + } + + fn workspace_routes(&self, workspace_key: &str) -> BTreeMap { + self.muxes + .lock() + .expect("external tool router lock poisoned") + .iter() + .filter_map(|(name, mux)| { + mux.routes + .read() + .expect("external tool route lock poisoned") + .get(workspace_key) + .cloned() + .map(|route| (name.clone(), route)) + }) + .collect() + } +} + +pub(crate) fn intercept_external_tool_registry_registration(tool: Arc) -> Arc { + router().intercept_registration(tool) +} + +pub(crate) fn detach_external_tool_mcp_server(server_id: &str) -> Vec> { + router().detach_mcp_server(server_id) +} + +pub(crate) fn retain_external_tool_muxes_for_prefix(prefix: &str) -> Vec> { + router().retain_muxes_for_prefix(prefix) +} + +pub(crate) fn resolve_external_tool_for_workspace( + tool: Arc, + workspace_root: Option<&Path>, +) -> Option> { + router().resolve_registered_tool(tool, workspace_root) +} + +pub(crate) fn external_tool_route_root( + workspace_root: Option<&Path>, + is_remote: bool, +) -> Option<&Path> { + if is_remote { + // External-source workspace roots must be absolute. This deliberately + // non-routable key selects only the displaced local implementation + // while remote Tool Runtime ownership is unavailable. + Some(Path::new("\0")) + } else { + workspace_root + } +} + +struct LoadedTarget { + revision: String, + load_generation: u64, + tools: Vec>, +} + +struct ExternalToolRuntimeManager { + runtime: Arc, + loaded: Mutex>, + workspace_targets: Mutex>>, + lost_targets: Mutex>, +} + +struct LostExternalToolTarget { + workspace_key: String, + reason: String, + recovery_state: WorkerRecoveryState, +} + +#[derive(Clone, Debug)] +struct LostExternalToolStatus { + reason: String, + recovery_state: WorkerRecoveryState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorkerRecoveryState { + Pending, + Attempted, +} + +impl Default for ExternalToolRuntimeManager { + fn default() -> Self { + Self { + runtime: Arc::new(NodeScriptToolRuntime::discover()), + loaded: Mutex::new(HashMap::new()), + workspace_targets: Mutex::new(HashMap::new()), + lost_targets: Mutex::new(HashMap::new()), + } + } +} + +impl ExternalToolRuntimeManager { + async fn availability(&self) -> ScriptToolRuntimeAvailability { + self.runtime.availability().await + } + + async fn mark_worker_lost( + &self, + workspace_key: &str, + runtime_target_id: &str, + load_generation: u64, + reason: &str, + ) -> bool { + let mut loaded = self.loaded.lock().await; + if !loaded + .get(runtime_target_id) + .is_some_and(|target| target.load_generation == load_generation) + { + return false; + } + loaded.remove(runtime_target_id); + self.lost_targets + .lock() + .await + .entry(runtime_target_id.to_string()) + .and_modify(|lost| { + // The stdout monitor and an in-flight invocation can observe + // the same process exit. Preserve an already consumed budget + // so duplicate reports cannot create another auto-retry. + lost.reason = reason.to_string(); + }) + .or_insert_with(|| LostExternalToolTarget { + workspace_key: workspace_key.to_string(), + reason: reason.to_string(), + recovery_state: WorkerRecoveryState::Pending, + }); + drop(loaded); + true + } + + async fn lost_status( + &self, + runtime_target_id: &str, + worker_recovery_targets: &BTreeSet, + ) -> Option { + self.lost_targets + .lock() + .await + .get(runtime_target_id) + .filter(|_| !worker_recovery_targets.contains(runtime_target_id)) + .map(|lost| LostExternalToolStatus { + reason: lost.reason.clone(), + recovery_state: lost.recovery_state, + }) + } + + async fn workspace_requires_recovery(&self, workspace_key: &str) -> bool { + self.lost_targets.lock().await.values().any(|lost| { + lost.workspace_key == workspace_key + && lost.recovery_state == WorkerRecoveryState::Pending + }) + } + + async fn begin_workspace_recovery(&self, workspace_key: &str) -> BTreeSet { + let mut lost_targets = self.lost_targets.lock().await; + let mut claimed = BTreeSet::new(); + for (runtime_target_id, lost) in lost_targets.iter_mut() { + if lost.workspace_key == workspace_key + && lost.recovery_state == WorkerRecoveryState::Pending + { + // Consume the automatic budget before any fallible await in + // the caller. Cancellation therefore remains fail-closed and + // cannot strand a target in an in-between recovery state. + lost.recovery_state = WorkerRecoveryState::Attempted; + claimed.insert(runtime_target_id.clone()); + } + } + claimed + } + + async fn reset_workspace_recovery_budget(&self, workspace_key: &str) { + for lost in self.lost_targets.lock().await.values_mut() { + if lost.workspace_key == workspace_key + && lost.recovery_state == WorkerRecoveryState::Attempted + { + lost.recovery_state = WorkerRecoveryState::Pending; + } + } + } + + async fn ensure_loaded( + &self, + workspace_key: &str, + provider_id: &str, + approval_key: &str, + source_preference_key: &str, + prepared: PreparedExternalToolTarget, + ) -> Result>, String> { + let runtime_target_id = runtime_target_id(workspace_key, &prepared.target_id); + static NEXT_LOAD_GENERATION: AtomicU64 = AtomicU64::new(1); + let worktree_root = prepared.worktree_root.clone(); + let cached = self + .loaded + .lock() + .await + .get(&runtime_target_id) + .map(|target| (target.revision.clone(), target.tools.clone())); + if let Some((revision, tools)) = cached { + if revision == prepared.content_version + && self.runtime.is_loaded(&runtime_target_id).await + { + return Ok(tools); + } + } + self.loaded.lock().await.remove(&runtime_target_id); + let request = ScriptToolLoadRequest { + target_id: runtime_target_id.clone(), + revision: prepared.content_version.clone(), + module_source: prepared.module_source, + module_url: prepared.module_url, + working_directory: prepared.working_directory, + expected_tools: prepared + .expected_tools + .into_iter() + .map(|tool| ScriptToolExpectedExport { + export_name: tool.export_name, + tool_name: tool.tool_name, + }) + .collect(), + }; + let loaded = match self.runtime.load(request).await { + Ok(loaded) => loaded, + Err(error) => { + self.loaded.lock().await.remove(&runtime_target_id); + return Err(error.to_string()); + } + }; + let load_generation = NEXT_LOAD_GENERATION.fetch_add(1, Ordering::Relaxed); + let target_tool_names = Arc::new( + loaded + .tools + .iter() + .map(|descriptor| descriptor.name.clone()) + .collect::>(), + ); + let tools = loaded + .tools + .into_iter() + .map(|descriptor| { + Arc::new(LoadedExternalTool { + descriptor, + provider_id: provider_id.to_string(), + runtime_target_id: runtime_target_id.clone(), + load_generation, + revision: loaded.revision.clone(), + approval_key: approval_key.to_string(), + source_preference_key: source_preference_key.to_string(), + workspace_key: workspace_key.to_string(), + target_tool_names: Arc::clone(&target_tool_names), + worktree_root: worktree_root.clone(), + runtime: self.runtime.clone(), + }) + }) + .collect::>(); + self.loaded.lock().await.insert( + runtime_target_id.clone(), + LoadedTarget { + revision: loaded.revision.clone(), + load_generation, + tools: tools.clone(), + }, + ); + self.lost_targets.lock().await.remove(&runtime_target_id); + let monitor_runtime = Arc::clone(&self.runtime); + let monitor_runtime_target_id = runtime_target_id.clone(); + let monitor_revision = loaded.revision; + let monitor_load_generation = load_generation; + let monitor_workspace_key = workspace_key.to_string(); + let monitor_tool_names = Arc::clone(&target_tool_names); + tokio::spawn(async move { + if monitor_runtime + .wait_until_unloaded(&monitor_runtime_target_id) + .await + .is_err() + { + return; + } + let is_current = runtime_manager() + .loaded + .lock() + .await + .get(&monitor_runtime_target_id) + .is_some_and(|target| { + target.revision == monitor_revision + && target.load_generation == monitor_load_generation + }); + if is_current { + report_external_tool_worker_lost( + &monitor_workspace_key, + &monitor_runtime_target_id, + monitor_load_generation, + monitor_tool_names.as_ref(), + "tool process exited while idle".to_string(), + ) + .await; + } + }); + Ok(tools) + } + + async fn target_matches(&self, runtime_target_id: &str, revision: &str) -> bool { + let matches_revision = self + .loaded + .lock() + .await + .get(runtime_target_id) + .is_some_and(|target| target.revision == revision); + matches_revision && self.runtime.is_loaded(runtime_target_id).await + } + + async fn withdraw_target(&self, runtime_target_id: &str) { + self.loaded.lock().await.remove(runtime_target_id); + let _ = self.runtime.dispose(runtime_target_id).await; + } + + async fn reconcile_workspace_targets( + &self, + workspace_key: &str, + desired_targets: BTreeSet, + ) { + let previous = self + .workspace_targets + .lock() + .await + .insert(workspace_key.to_string(), desired_targets.clone()) + .unwrap_or_default(); + for target in previous.difference(&desired_targets) { + self.loaded.lock().await.remove(target); + let _ = self.runtime.dispose(target).await; + } + } + + async fn release_workspace(&self, workspace_key: &str) { + let targets = self + .workspace_targets + .lock() + .await + .remove(workspace_key) + .unwrap_or_default(); + for target in targets { + self.loaded.lock().await.remove(&target); + let _ = self.runtime.dispose(&target).await; + } + self.lost_targets + .lock() + .await + .retain(|_, lost| lost.workspace_key != workspace_key); + } +} + +fn router() -> &'static ExternalToolRouter { + static ROUTER: OnceLock = OnceLock::new(); + ROUTER.get_or_init(ExternalToolRouter::default) +} + +fn runtime_manager() -> &'static ExternalToolRuntimeManager { + static MANAGER: OnceLock = OnceLock::new(); + MANAGER.get_or_init(ExternalToolRuntimeManager::default) +} + +async fn report_external_tool_worker_lost( + workspace_key: &str, + runtime_target_id: &str, + load_generation: u64, + tool_names: &[String], + reason: String, +) { + if !runtime_manager() + .mark_worker_lost(workspace_key, runtime_target_id, load_generation, &reason) + .await + { + return; + } + router() + .withdraw_failed_target( + workspace_key, + runtime_target_id, + load_generation, + tool_names, + ) + .await; + crate::external_sources::notify_external_tool_registry_changed(); +} + +pub(super) async fn external_tool_workspace_requires_recovery( + workspace_root: Option<&Path>, +) -> bool { + runtime_manager() + .workspace_requires_recovery(&workspace_route_key(workspace_root)) + .await +} + +pub(super) async fn begin_external_tool_workspace_recovery( + workspace_root: Option<&Path>, +) -> BTreeSet { + runtime_manager() + .begin_workspace_recovery(&workspace_route_key(workspace_root)) + .await +} + +pub(super) async fn reset_external_tool_workspace_recovery_budget(workspace_root: Option<&Path>) { + runtime_manager() + .reset_workspace_recovery_budget(&workspace_route_key(workspace_root)) + .await; +} + +pub(super) async fn release_external_tool_workspace(workspace_root: Option<&Path>) { + let workspace_key = workspace_route_key(workspace_root); + router().apply_routes(&workspace_key, BTreeMap::new()).await; + runtime_manager().release_workspace(&workspace_key).await; +} + +pub(super) async fn reconcile_external_tools( + workspace_root: Option<&Path>, + execution_domain_id: &str, + coordinator: &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 mut state = ExternalToolProductState::default(); + let source_by_key = snapshot + .sources + .iter() + .map(|source| (source.record.key.clone(), source.record.clone())) + .collect::>(); + let runtime_availability = runtime_manager().availability().await; + let mut target_groups = + BTreeMap::>::new(); + for tool in snapshot.tools { + target_groups + .entry(tool.id.target.clone()) + .or_default() + .push(tool); + } + let discovered_names = target_groups + .values() + .flatten() + .map(|tool| tool.name.clone()) + .collect::>(); + let mut names_to_quiesce = BTreeSet::new(); + let mut preapproved_runtime_targets = BTreeSet::new(); + for (target_id, definitions) in &target_groups { + let first = &definitions[0]; + let approval_key = external_tool_approval_key( + execution_domain_id, + target_id, + first.runtime_kind, + first.capabilities.iter().copied(), + ); + let statically_ready = definitions + .iter() + .all(|tool| matches!(&tool.static_status, ExternalToolStaticStatus::Ready)); + let can_load = statically_ready + && matches!( + &runtime_availability, + ScriptToolRuntimeAvailability::Available { .. } + ) + && decisions.approved_targets.contains(&approval_key); + let runtime_target = runtime_target_id(&workspace_key, target_id); + let matches_loaded = can_load + && runtime_manager() + .target_matches(&runtime_target, &first.content_version) + .await; + if can_load { + preapproved_runtime_targets.insert(runtime_target.clone()); + } + if !matches_loaded { + names_to_quiesce.extend(definitions.iter().map(|tool| tool.name.clone())); + runtime_manager().withdraw_target(&runtime_target).await; + } + } + // Preserve healthy, byte-identical routes while other targets prepare. + // Removed, disabled and changed targets are withdrawn before slow work. + let mut preliminary_routes = router().workspace_routes(&workspace_key); + retain_fail_closed_routes_during_reconcile(&mut preliminary_routes, &discovered_names); + for name in names_to_quiesce { + if !preliminary_routes.contains_key(&name) { + continue; + } + let route = match preliminary_routes.get(&name) { + Some(WorkspaceRoute::External { conflict, .. }) => WorkspaceRoute::Unavailable { + conflict: conflict.clone(), + }, + _ if router().original_tool(&name).await.is_some() => { + WorkspaceRoute::Original { conflict: None } + } + _ => WorkspaceRoute::Unavailable { conflict: None }, + }; + preliminary_routes.insert(name, route); + } + router() + .apply_routes(&workspace_key, preliminary_routes) + .await; + runtime_manager() + .reconcile_workspace_targets(&workspace_key, preapproved_runtime_targets) + .await; + + let mut conflict_candidates_by_name = BTreeMap::>::new(); + let mut loaded_by_candidate_id = BTreeMap::>::new(); + let mut desired_runtime_targets = BTreeSet::new(); + let mut entries = BTreeMap::::new(); + + for (target_id, definitions) in target_groups { + let first = &definitions[0]; + let approval_key = external_tool_approval_key( + execution_domain_id, + &target_id, + first.runtime_kind, + first.capabilities.iter().copied(), + ); + let decision_key = external_tool_decision_key(&approval_key, &first.content_version); + let unsupported_reason = definitions + .iter() + .find_map(|tool| match &tool.static_status { + ExternalToolStaticStatus::Ready => None, + ExternalToolStaticStatus::Unsupported { reason } + | ExternalToolStaticStatus::Invalid { reason } => Some(reason.clone()), + _ => Some("tool uses a static format not supported by this version".to_string()), + }); + let base_activation = if let Some(reason) = unsupported_reason { + Some(ExternalToolActivationState::Unsupported { reason }) + } else if let ScriptToolRuntimeAvailability::Unavailable { reason } = &runtime_availability + { + Some(ExternalToolActivationState::RuntimeUnavailable { + reason: reason.clone(), + }) + } else if !decisions.approved_targets.contains(&approval_key) { + let activation = if decisions + .declined_decisions_by_approval + .get(&approval_key) + .is_some_and(|declined| declined == &decision_key) + { + ExternalToolActivationState::Disabled + } else { + let source = source_by_key.get(&target_id.source); + state.approval_requests.push(ExternalToolApprovalRequest { + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + target_id: target_id.clone(), + source_display_name: source + .map(|source| source.display_name.clone()) + .unwrap_or_else(|| "External tools".to_string()), + source_scope: source + .map(|source| source.scope) + .unwrap_or(ExternalSourceScope::WorkspaceLocal), + source_location: source + .map(|source| source.location.clone()) + .unwrap_or_else(|| first.module_path.clone()), + working_directory: first.working_directory.clone(), + runtime_kind: first.runtime_kind, + capabilities: first.capabilities.clone(), + content_version: first.content_version.clone(), + tool_names: definitions.iter().map(|tool| tool.name.clone()).collect(), + }); + ExternalToolActivationState::ApprovalRequired + }; + Some(activation) + } else { + None + }; + + if let Some(activation) = base_activation { + if matches!( + activation, + ExternalToolActivationState::RuntimeUnavailable { .. } + ) && decisions.approved_targets.contains(&approval_key) + { + if let Some(source) = source_by_key.get(&target_id.source) { + if crate::external_sources::external_tool_invocation_is_authorized( + &approval_key, + &source.preference_key(), + ) + .await + .unwrap_or(false) + { + for definition in &definitions { + conflict_candidates_by_name + .entry(definition.name.clone()) + .or_default() + .push(definition.clone()); + } + } + } + } + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: activation.clone(), + }, + ); + } + continue; + } + + let Some(source_record) = source_by_key.get(&target_id.source) else { + state.diagnostics.push(tool_diagnostic( + "external_tool.source_record_missing", + "External tool source metadata is missing; the target was not loaded.", + Some(target_id.source.clone()), + )); + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::LoadFailed { + reason: "source metadata is unavailable".to_string(), + }, + }, + ); + } + continue; + }; + let source_preference_key = source_record.preference_key(); + match crate::external_sources::external_tool_invocation_is_authorized( + &approval_key, + &source_preference_key, + ) + .await + { + Ok(true) => {} + Ok(false) => { + runtime_manager() + .withdraw_target(&runtime_target_id(&workspace_key, &target_id)) + .await; + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::Disabled, + }, + ); + } + continue; + } + Err(error) => { + state.diagnostics.push(tool_diagnostic( + "external_tool.preference_read_failed", + format!("Could not verify the current external tool decision: {error}"), + Some(target_id.source.clone()), + )); + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::LoadFailed { + reason: "current approval could not be verified".to_string(), + }, + }, + ); + } + continue; + } + } + + for definition in &definitions { + conflict_candidates_by_name + .entry(definition.name.clone()) + .or_default() + .push(definition.clone()); + } + + let runtime_target = runtime_target_id(&workspace_key, &target_id); + if let Some(loss) = runtime_manager() + .lost_status(&runtime_target, worker_recovery_targets) + .await + { + state.diagnostics.push(tool_diagnostic( + "external_tool.worker_lost", + "External tool process stopped and its route was withdrawn.", + Some(target_id.source.clone()), + )); + log::warn!( + "External tool process stopped for target '{}': {}", + runtime_target, + loss.reason + ); + let retry_guidance = match loss.recovery_state { + WorkerRecoveryState::Pending => { + "tool process stopped; one automatic recovery will run before the next catalog is exposed" + } + WorkerRecoveryState::Attempted => { + "automatic recovery did not restore the tool; refresh external tools or update the source to retry" + } + }; + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::LoadFailed { + reason: retry_guidance.to_string(), + }, + }, + ); + } + continue; + } + + let preparation_coordinator = Arc::clone(coordinator); + 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) + }) + .await + .map_err(|error| format!("tool preparation task failed: {error}")) + .and_then(|result| result.map_err(|error| error.to_string())); + match prepared { + Ok(prepared) => { + let authorization_failure = + match crate::external_sources::external_tool_invocation_is_authorized( + &approval_key, + &source_preference_key, + ) + .await + { + Ok(true) => None, + Ok(false) => Some(ExternalToolActivationState::Disabled), + Err(error) => { + state.diagnostics.push(tool_diagnostic( + "external_tool.preference_read_failed", + format!( + "Could not verify approval immediately before importing the external tool: {error}" + ), + Some(target_id.source.clone()), + )); + Some(ExternalToolActivationState::LoadFailed { + reason: "current approval could not be verified".to_string(), + }) + } + }; + if let Some(activation) = authorization_failure { + runtime_manager() + .withdraw_target(&runtime_target_id(&workspace_key, &target_id)) + .await; + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: activation.clone(), + }, + ); + } + continue; + } + match runtime_manager() + .ensure_loaded( + &workspace_key, + target_id.source.provider_id.as_str(), + &approval_key, + &source_preference_key, + prepared, + ) + .await + { + Ok(loaded) => { + match crate::external_sources::external_tool_invocation_is_authorized( + &approval_key, + &source_preference_key, + ) + .await + { + Ok(true) => {} + Ok(false) => { + runtime_manager() + .withdraw_target(&runtime_target_id(&workspace_key, &target_id)) + .await; + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::Disabled, + }, + ); + } + continue; + } + Err(error) => { + runtime_manager() + .withdraw_target(&runtime_target_id(&workspace_key, &target_id)) + .await; + state.diagnostics.push(tool_diagnostic( + "external_tool.preference_read_failed", + format!( + "Could not verify approval after loading the external tool: {error}" + ), + Some(target_id.source.clone()), + )); + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::LoadFailed { + reason: "current approval could not be verified" + .to_string(), + }, + }, + ); + } + continue; + } + } + desired_runtime_targets + .insert(runtime_target_id(&workspace_key, &target_id)); + let loaded_by_export = loaded + .into_iter() + .map(|tool| (tool.descriptor.export_name.clone(), tool)) + .collect::>(); + for definition in definitions { + let Some(loaded) = loaded_by_export + .get(definition.id.export_id.as_str()) + .cloned() + else { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::LoadFailed { + reason: + "worker did not return the expected tool export" + .to_string(), + }, + }, + ); + continue; + }; + loaded_by_candidate_id.insert(definition.candidate_id(), loaded); + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::Active, + }, + ); + } + } + Err(error) => { + state.diagnostics.push(tool_diagnostic( + "external_tool.load_failed", + format!("Failed to load '{}': {error}", first.module_path), + Some(target_id.source.clone()), + )); + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::LoadFailed { + reason: error.clone(), + }, + }, + ); + } + } + } + } + Err(error) => { + state.diagnostics.push(tool_diagnostic( + "external_tool.prepare_failed", + error.to_string(), + Some(target_id.source.clone()), + )); + for definition in definitions { + entries.insert( + definition.id.stable_key(), + ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: ExternalToolActivationState::LoadFailed { + reason: error.to_string(), + }, + }, + ); + } + } + } + } + + runtime_manager() + .reconcile_workspace_targets(&workspace_key, desired_runtime_targets) + .await; + + let conflict_domain = workspace_conflict_domain(execution_domain_id, &workspace_key); + let mut names_by_normalized = BTreeMap::>::new(); + for name in conflict_candidates_by_name.keys() { + names_by_normalized + .entry(name.clone()) + .or_default() + .push(name.clone()); + } + let conflict_prefix = format!("external_tool:{conflict_domain}:"); + for conflict_key in decisions.conflict_choices.keys() { + let Some(rest) = conflict_key.strip_prefix(&conflict_prefix) else { + continue; + }; + let Some((normalized_name, _)) = rest.rsplit_once(':') else { + continue; + }; + let names = names_by_normalized + .entry(normalized_name.to_string()) + .or_default(); + if names.is_empty() { + names.push( + router() + .known_name(normalized_name) + .unwrap_or_else(|| normalized_name.to_string()), + ); + } + } + + let mut routes = BTreeMap::new(); + for name in names_by_normalized.into_values().flatten() { + let external_candidates = conflict_candidates_by_name + .remove(&name) + .unwrap_or_default(); + let original = router().original_tool(&name).await; + if external_candidates.is_empty() && original.is_none() { + continue; + } + let mut candidates = Vec::new(); + if let Some(original) = &original { + candidates.push(local_candidate(original).await); + } + for definition in &external_candidates { + let candidate_id = definition.candidate_id(); + candidates.push(ExternalToolConflictCandidate { + candidate_id, + display_name: definition.name.clone(), + kind: ExternalToolConflictCandidateKind::External, + provider_id: definition.id.target.source.provider_id.to_string(), + content_version: definition.content_version.clone(), + source: Some(definition.id.target.source.clone()), + source_location: Some(definition.module_path.clone()), + }); + } + + let has_conflict_history = if external_candidates.is_empty() { + tool_conflict_history_requires_fail_closed( + decisions.conflict_choices, + &conflict_domain, + &name, + ) + } else { + has_tool_conflict_history(decisions.conflict_choices, &conflict_domain, &name) + }; + if candidates.len() == 1 && !has_conflict_history { + if let Some(definition) = external_candidates.first() { + let route = loaded_by_candidate_id + .get(&definition.candidate_id()) + .cloned() + .map_or(WorkspaceRoute::Unavailable { conflict: None }, |tool| { + WorkspaceRoute::External { + tool, + conflict: None, + } + }); + routes.insert(name, route); + } + continue; + } + + let conflict_key = external_tool_conflict_key( + &conflict_domain, + &name, + candidates.iter().map(|candidate| { + ( + candidate.candidate_id.as_str(), + candidate.content_version.as_str(), + ) + }), + ); + let external_candidate_ids = external_candidates + .iter() + .map(ExternalToolDefinition::candidate_id) + .collect::>(); + let (selected, route_choice) = resolve_conflict_route_choice( + &conflict_key, + &candidates, + &external_candidate_ids, + original.is_some(), + decisions.conflict_choices, + ); + let conflict = Some(ConflictExpectation { + key: conflict_key.clone(), + selected_candidate_id: selected.clone(), + }); + let route = match route_choice { + ConflictRouteChoice::External(candidate_id) => { + loaded_by_candidate_id.get(&candidate_id).cloned().map_or( + WorkspaceRoute::Unavailable { + conflict: conflict.clone(), + }, + |tool| WorkspaceRoute::External { + tool, + conflict: conflict.clone(), + }, + ) + } + ConflictRouteChoice::Original => WorkspaceRoute::Original { + conflict: conflict.clone(), + }, + ConflictRouteChoice::Unavailable => WorkspaceRoute::Unavailable { conflict }, + }; + routes.insert(name.clone(), route); + for definition in external_candidates { + let selected_external = selected + .as_deref() + .is_some_and(|selected| selected == definition.candidate_id()); + if let Some(entry) = entries.get_mut(&definition.id.stable_key()) { + entry.activation = if selected_external + && loaded_by_candidate_id.contains_key(&definition.candidate_id()) + { + ExternalToolActivationState::Active + } else if selected_external { + entry.activation.clone() + } else { + ExternalToolActivationState::Conflict + }; + } + } + state.conflicts.push(ExternalToolConflict { + conflict_key, + tool_name: name, + candidates, + selected_candidate_id: selected, + }); + } + router().apply_routes(&workspace_key, routes).await; + + state.tools = entries.into_values().collect(); + state + .tools + .sort_by(|left, right| left.definition.name.cmp(&right.definition.name)); + state + .approval_requests + .sort_by(|left, right| left.source_location.cmp(&right.source_location)); + state + .conflicts + .sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); + state +} + +async fn local_candidate(tool: &Arc) -> ExternalToolConflictCandidate { + let dynamic = tool.dynamic_tool_info(); + let kind = if dynamic + .as_ref() + .and_then(|info| info.mcp.as_ref()) + .is_some() + { + ExternalToolConflictCandidateKind::Mcp + } else { + ExternalToolConflictCandidateKind::BuiltIn + }; + let provider_id = dynamic + .as_ref() + .map(|info| info.provider_id.clone()) + .unwrap_or_else(|| "bitfun.builtin".to_string()); + let candidate_id = format!("registry:{provider_id}:{}", tool.name()); + let description = tool.description().await.unwrap_or_default(); + let schema = serde_json::to_vec(&tool.input_schema()).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(tool.name().as_bytes()); + hasher.update([0]); + hasher.update(provider_id.as_bytes()); + hasher.update([0]); + hasher.update(description.as_bytes()); + hasher.update([0]); + hasher.update(schema); + ExternalToolConflictCandidate { + candidate_id, + display_name: tool.name().to_string(), + kind, + provider_id, + content_version: format!("sha256:{}", hex::encode(hasher.finalize())), + source: None, + source_location: None, + } +} + +fn workspace_route_key(workspace_root: Option<&Path>) -> String { + workspace_root + .map(|path| { + dunce::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .into_owned() + }) + .unwrap_or_else(|| "".to_string()) +} + +fn workspace_conflict_domain(execution_domain_id: &str, workspace_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(workspace_key.as_bytes()); + format!( + "{}-workspace-{}", + execution_domain_id, + hex::encode(&hasher.finalize()[..12]) + ) +} + +fn runtime_target_id(workspace_key: &str, target: &SourceQualifiedToolTargetId) -> String { + let mut hasher = Sha256::new(); + hasher.update(workspace_key.as_bytes()); + hasher.update([0]); + hasher.update(target.stable_key().as_bytes()); + format!("external-tool-{}", hex::encode(hasher.finalize())) +} + +fn tool_diagnostic( + code: impl Into, + message: impl Into, + source: Option, +) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic { + severity: ExternalSourceDiagnosticSeverity::Warning, + code: code.into(), + message: message.into(), + source, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestTool { + name: String, + } + + #[async_trait] + impl Tool for TestTool { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> BitFunResult { + Ok("test tool".to_string()) + } + + fn short_description(&self) -> String { + "test tool".to_string() + } + + fn input_schema(&self) -> Value { + serde_json::json!({ "type": "object" }) + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(Vec::new()) + } + } + + fn candidate( + id: &str, + kind: ExternalToolConflictCandidateKind, + ) -> ExternalToolConflictCandidate { + ExternalToolConflictCandidate { + candidate_id: id.to_string(), + display_name: id.to_string(), + kind, + provider_id: id.to_string(), + content_version: "v1".to_string(), + source: None, + source_location: None, + } + } + + async fn seed_loaded_generation( + manager: &ExternalToolRuntimeManager, + runtime_target_id: &str, + load_generation: u64, + ) { + manager.loaded.lock().await.insert( + runtime_target_id.to_string(), + LoadedTarget { + revision: "v1".to_string(), + load_generation, + tools: Vec::new(), + }, + ); + } + + #[test] + fn unresolved_conflict_preserves_an_existing_local_tool() { + let candidates = vec![ + candidate("builtin", ExternalToolConflictCandidateKind::BuiltIn), + candidate("external", ExternalToolConflictCandidateKind::External), + ]; + let (selected, route) = resolve_conflict_route_choice( + "conflict-v1", + &candidates, + &BTreeSet::from(["external".to_string()]), + true, + &BTreeMap::new(), + ); + + assert_eq!(selected, None); + assert_eq!(route, ConflictRouteChoice::Original); + } + + #[test] + fn unresolved_external_only_conflict_is_unavailable_until_the_user_chooses() { + let candidates = vec![ + candidate("external-a", ExternalToolConflictCandidateKind::External), + candidate("external-b", ExternalToolConflictCandidateKind::External), + ]; + let external_ids = BTreeSet::from(["external-a".to_string(), "external-b".to_string()]); + let (selected, route) = resolve_conflict_route_choice( + "conflict-v1", + &candidates, + &external_ids, + false, + &BTreeMap::new(), + ); + + assert_eq!(selected, None); + assert_eq!(route, ConflictRouteChoice::Unavailable); + } + + #[test] + fn conflict_choice_accepts_only_a_candidate_from_the_current_fingerprint() { + let candidates = vec![ + candidate("builtin", ExternalToolConflictCandidateKind::BuiltIn), + candidate("external", ExternalToolConflictCandidateKind::External), + ]; + let external_ids = BTreeSet::from(["external".to_string()]); + let choices = BTreeMap::from([ + ("conflict-v1".to_string(), "external".to_string()), + ("conflict-v2".to_string(), "deleted".to_string()), + ]); + + let (selected, route) = resolve_conflict_route_choice( + "conflict-v1", + &candidates, + &external_ids, + true, + &choices, + ); + assert_eq!(selected.as_deref(), Some("external")); + assert_eq!(route, ConflictRouteChoice::External("external".to_string())); + + let (selected, route) = resolve_conflict_route_choice( + "conflict-v2", + &candidates, + &external_ids, + true, + &choices, + ); + assert_eq!(selected, None); + assert_eq!(route, ConflictRouteChoice::Original); + } + + #[test] + fn changed_conflict_stays_unavailable_after_an_external_choice() { + let candidates = vec![candidate( + "registry:bitfun.builtin:read", + ExternalToolConflictCandidateKind::BuiltIn, + )]; + let choices = BTreeMap::from([( + "external_tool:domain:read:old".to_string(), + "external:source-a".to_string(), + )]); + + let (selected, route) = resolve_conflict_route_choice( + "external_tool:domain:read:new", + &candidates, + &BTreeSet::new(), + true, + &choices, + ); + + assert_eq!(selected, None); + assert_eq!(route, ConflictRouteChoice::Unavailable); + assert!(has_tool_conflict_history(&choices, "domain", "read")); + } + + #[tokio::test] + async fn concurrent_workspace_routes_install_one_shared_mux() { + let router = Arc::new(ExternalToolRouter::default()); + let tool_name = "external_router_concurrent_install_contract".to_string(); + let route = || { + BTreeMap::from([( + tool_name.clone(), + WorkspaceRoute::Unavailable { conflict: None }, + )]) + }; + tokio::join!( + router.apply_routes("workspace-a", route()), + router.apply_routes("workspace-b", route()) + ); + + let mux = router + .muxes + .lock() + .expect("router lock") + .get(&tool_name) + .cloned() + .expect("shared mux"); + assert_eq!(mux.route_count(), 2); + assert!(mux.original().is_none(), "a mux was nested as the original"); + + router.apply_routes("workspace-a", BTreeMap::new()).await; + router.apply_routes("workspace-b", BTreeMap::new()).await; + router.muxes.lock().expect("router lock").remove(&tool_name); + get_global_tool_registry() + .write() + .await + .unregister_tool(&tool_name); + } + + #[tokio::test] + async fn last_route_removal_keeps_concurrent_registration_behind_the_mux() { + let tool_name = "external_router_last_route_registration_contract".to_string(); + let workspace_key = "external-router-last-route-workspace"; + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + get_global_tool_registry() + .write() + .await + .register_tool_without_external_source_notification(original); + router() + .apply_routes( + workspace_key, + BTreeMap::from([( + tool_name.clone(), + WorkspaceRoute::Unavailable { conflict: None }, + )]), + ) + .await; + + let replacement: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let replacement_for_registration = replacement.clone(); + let start = Arc::new(tokio::sync::Barrier::new(3)); + let removal_start = start.clone(); + let removal = tokio::spawn(async move { + removal_start.wait().await; + router().apply_routes(workspace_key, BTreeMap::new()).await; + }); + let registration_start = start.clone(); + let registration = tokio::spawn(async move { + registration_start.wait().await; + get_global_tool_registry() + .write() + .await + .register_tool(replacement_for_registration); + }); + start.wait().await; + removal.await.expect("route removal task"); + registration.await.expect("tool registration task"); + + let mux = router() + .muxes + .lock() + .expect("router lock") + .get(&tool_name) + .cloned() + .expect("zero-route mux remains installed"); + let registered = get_global_tool_registry() + .read() + .await + .get_tool(&tool_name) + .expect("registered mux"); + let routed: Arc = mux.clone(); + assert!(Arc::ptr_eq(®istered, &routed)); + assert!(Arc::ptr_eq( + &mux.original().expect("replacement original"), + &replacement + )); + + router() + .muxes + .lock() + .expect("router lock") + .remove(&tool_name); + get_global_tool_registry() + .write() + .await + .unregister_tool(&tool_name); + } + + #[tokio::test] + async fn worker_loss_withdraws_the_external_route_without_falling_back() { + let router = ExternalToolRouter::default(); + let tool_name = "external_worker_loss_route_contract".to_string(); + let workspace_key = "worker-loss-workspace"; + let runtime_target_id = "worker-loss-target"; + let tool = Arc::new(LoadedExternalTool { + descriptor: ScriptToolDescriptor { + export_name: "run".to_string(), + name: tool_name.clone(), + description: "test".to_string(), + input_schema: serde_json::json!({"type": "object"}), + }, + provider_id: "test-provider".to_string(), + runtime_target_id: runtime_target_id.to_string(), + load_generation: 7, + revision: "v1".to_string(), + approval_key: "approval".to_string(), + source_preference_key: "test:source".to_string(), + workspace_key: workspace_key.to_string(), + target_tool_names: Arc::new(vec![tool_name.clone()]), + worktree_root: None, + runtime: Arc::new(NodeScriptToolRuntime::discover()), + }); + let mux = Arc::new(ExternalToolMux::new(tool_name.clone(), None)); + mux.set_route( + workspace_key.to_string(), + WorkspaceRoute::External { + tool, + conflict: None, + }, + ); + router + .muxes + .lock() + .expect("router lock") + .insert(tool_name.clone(), mux.clone()); + + router + .withdraw_failed_target(workspace_key, runtime_target_id, 7, &[tool_name.clone()]) + .await; + + assert!(matches!( + router.workspace_routes(workspace_key).get(&tool_name), + Some(WorkspaceRoute::Unavailable { .. }) + )); + + let replacement = Arc::new(LoadedExternalTool { + descriptor: ScriptToolDescriptor { + export_name: "run".to_string(), + name: tool_name.clone(), + description: "replacement".to_string(), + input_schema: serde_json::json!({"type": "object"}), + }, + provider_id: "test-provider".to_string(), + runtime_target_id: runtime_target_id.to_string(), + load_generation: 8, + revision: "v1".to_string(), + approval_key: "approval".to_string(), + source_preference_key: "test:source".to_string(), + workspace_key: workspace_key.to_string(), + target_tool_names: Arc::new(vec![tool_name.clone()]), + worktree_root: None, + runtime: Arc::new(NodeScriptToolRuntime::discover()), + }); + mux.set_route( + workspace_key.to_string(), + WorkspaceRoute::External { + tool: replacement, + conflict: None, + }, + ); + router + .withdraw_failed_target(workspace_key, runtime_target_id, 7, &[tool_name.clone()]) + .await; + assert!(matches!( + router.workspace_routes(workspace_key).get(&tool_name), + Some(WorkspaceRoute::External { tool, .. }) if tool.load_generation == 8 + )); + } + + #[test] + fn source_removal_quiesces_a_selected_external_conflict_without_original_fallback() { + let tool_name = "removed_external_conflict_contract".to_string(); + let tool = Arc::new(LoadedExternalTool { + descriptor: ScriptToolDescriptor { + export_name: "run".to_string(), + name: tool_name.clone(), + description: "test".to_string(), + input_schema: serde_json::json!({"type": "object"}), + }, + provider_id: "test-provider".to_string(), + runtime_target_id: "target".to_string(), + load_generation: 8, + revision: "v1".to_string(), + approval_key: "approval".to_string(), + source_preference_key: "test:source".to_string(), + workspace_key: "workspace".to_string(), + target_tool_names: Arc::new(vec![tool_name.clone()]), + worktree_root: None, + runtime: Arc::new(NodeScriptToolRuntime::discover()), + }); + let expectation = ConflictExpectation { + key: "external_tool:domain:removed_external_conflict_contract:v1".to_string(), + selected_candidate_id: Some("external:target".to_string()), + }; + let mut routes = BTreeMap::from([( + tool_name.clone(), + WorkspaceRoute::External { + tool, + conflict: Some(expectation), + }, + )]); + + retain_fail_closed_routes_during_reconcile(&mut routes, &BTreeSet::new()); + + assert!(matches!( + routes.get(&tool_name), + Some(WorkspaceRoute::Unavailable { conflict: Some(_) }) + )); + } + + #[tokio::test] + async fn remote_same_path_call_uses_original_instead_of_the_local_external_route() { + use crate::agentic::tools::restrictions::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; + use std::collections::HashMap; + + let root = std::env::current_dir().expect("absolute test root"); + let tool_name = "remote_same_path_external_route_contract".to_string(); + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let external = Arc::new(LoadedExternalTool { + descriptor: ScriptToolDescriptor { + export_name: "run".to_string(), + name: tool_name.clone(), + description: "external".to_string(), + input_schema: serde_json::json!({"type": "object"}), + }, + provider_id: "test-provider".to_string(), + runtime_target_id: "not-loaded".to_string(), + load_generation: 9, + revision: "v1".to_string(), + approval_key: "approval".to_string(), + source_preference_key: "test:source".to_string(), + workspace_key: workspace_route_key(Some(&root)), + target_tool_names: Arc::new(vec![tool_name.clone()]), + worktree_root: None, + runtime: Arc::new(NodeScriptToolRuntime::discover()), + }); + assert!(!external.manages_own_execution_timeout()); + let mux = ExternalToolMux::new(tool_name, Some(original)); + assert!(!mux.manages_own_execution_timeout()); + mux.set_route( + workspace_route_key(Some(&root)), + WorkspaceRoute::External { + tool: external, + conflict: None, + }, + ); + let session_identity = + crate::service::remote_ssh::workspace_state::workspace_session_identity( + root.to_string_lossy().as_ref(), + Some("remote-connection"), + Some("remote.example"), + ) + .expect("remote session identity"); + let context = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new_remote( + None, + root, + "remote-connection".to_string(), + "Remote".to_string(), + session_identity, + )), + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + }; + + assert!(!mux + .selected(Some(&context)) + .expect("remote original") + .manages_own_execution_timeout()); + assert!(mux.call(&serde_json::json!({}), &context).await.is_ok()); + } + + #[tokio::test] + async fn worker_loss_allows_exactly_one_catalog_recovery_attempt() { + let manager = ExternalToolRuntimeManager::default(); + seed_loaded_generation(&manager, "worker-loss-target", 10).await; + assert!( + manager + .mark_worker_lost( + "worker-loss-workspace", + "worker-loss-target", + 10, + "worker exited", + ) + .await + ); + + assert!( + manager + .workspace_requires_recovery("worker-loss-workspace") + .await + ); + let claimed = manager + .begin_workspace_recovery("worker-loss-workspace") + .await; + assert_eq!(claimed, BTreeSet::from(["worker-loss-target".to_string()])); + assert!( + !manager + .workspace_requires_recovery("worker-loss-workspace") + .await + ); + assert!(manager + .begin_workspace_recovery("worker-loss-workspace") + .await + .is_empty()); + let attempted = manager + .lost_status("worker-loss-target", &BTreeSet::new()) + .await + .expect("loss remains fail-closed after the automatic budget is consumed"); + assert_eq!(attempted.recovery_state, WorkerRecoveryState::Attempted); + assert!( + !manager + .mark_worker_lost( + "worker-loss-workspace", + "worker-loss-target", + 10, + "duplicate exit report", + ) + .await + ); + assert!( + !manager + .workspace_requires_recovery("worker-loss-workspace") + .await + ); + + manager + .reset_workspace_recovery_budget("worker-loss-workspace") + .await; + assert!( + manager + .workspace_requires_recovery("worker-loss-workspace") + .await + ); + assert_eq!( + manager + .begin_workspace_recovery("worker-loss-workspace") + .await, + BTreeSet::from(["worker-loss-target".to_string()]) + ); + } + + #[tokio::test] + async fn concurrent_catalog_recovery_claims_a_lost_target_once() { + let manager = Arc::new(ExternalToolRuntimeManager::default()); + seed_loaded_generation(&manager, "worker-loss-target", 11).await; + assert!( + manager + .mark_worker_lost("worker-loss-workspace", "worker-loss-target", 11, "exited",) + .await + ); + let claims = futures::future::join_all((0..8).map(|_| { + let manager = Arc::clone(&manager); + async move { + manager + .begin_workspace_recovery("worker-loss-workspace") + .await + } + })) + .await; + + assert_eq!(claims.iter().filter(|claim| !claim.is_empty()).count(), 1); + assert_eq!( + claims.into_iter().flatten().collect::>(), + vec!["worker-loss-target".to_string()] + ); + } + + #[tokio::test] + async fn stale_generation_exit_cannot_withdraw_or_mark_a_reloaded_target() { + let manager = ExternalToolRuntimeManager::default(); + seed_loaded_generation(&manager, "reloaded-target", 22).await; + + assert!( + !manager + .mark_worker_lost("workspace", "reloaded-target", 21, "old worker exited") + .await + ); + assert_eq!( + manager + .loaded + .lock() + .await + .get("reloaded-target") + .map(|target| target.load_generation), + Some(22) + ); + assert!(manager.lost_targets.lock().await.is_empty()); + } +} + +pub(super) fn merge_tool_state( + mut snapshot: bitfun_product_domains::external_sources::ExternalSourceCatalogSnapshot, + tool_snapshot: &ExternalToolCoordinatorSnapshot, + state: ExternalToolProductState, +) -> bitfun_product_domains::external_sources::ExternalSourceCatalogSnapshot { + snapshot.generation = snapshot.generation.max(tool_snapshot.generation); + snapshot.discovery_pending |= tool_snapshot.discovery_pending; + snapshot.sources.extend(tool_snapshot.sources.clone()); + snapshot + .sources + .sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + snapshot.tools = state.tools; + snapshot.tool_approval_requests = state.approval_requests; + snapshot.tool_conflicts = state.conflicts; + snapshot + .diagnostics + .extend(tool_snapshot.diagnostics.clone()); + snapshot.diagnostics.extend(state.diagnostics); + snapshot +} diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 929dc37d57..c1c0c098f6 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agentic; // Agent system, tool system, and product runtime orchestration #[cfg(feature = "product-full")] pub mod external_sources; +#[cfg(feature = "product-full")] +mod external_tools; #[cfg(feature = "product-domains")] pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index 155657e244..2545b81eea 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -4,6 +4,13 @@ //! branches on ecosystem identity. Concrete provider selection remains in the //! product composition root. +mod tool; + +pub use tool::{ + ExternalToolCoordinator, ExternalToolCoordinatorSnapshot, ExternalToolDiscoveryRequest, + ExternalToolDiscoveryResult, +}; + use bitfun_product_domains::external_sources::{ prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, ExternalSourceDiagnostic, @@ -142,6 +149,9 @@ impl ExternalSourceCoordinator { sources: Vec::new(), commands: Vec::new(), command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), diagnostics: Vec::new(), }, }) @@ -628,6 +638,9 @@ impl ExternalSourceCoordinator { sources, commands, command_conflicts, + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), diagnostics, }; self.snapshot.clone() diff --git a/src/crates/assembly/external-sources/src/tool.rs b/src/crates/assembly/external-sources/src/tool.rs new file mode 100644 index 0000000000..ae66f2873a --- /dev/null +++ b/src/crates/assembly/external-sources/src/tool.rs @@ -0,0 +1,364 @@ +use bitfun_product_domains::external_sources::{ + ExternalSourceCatalogEntry, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, ExternalSourceProviderError, + ExternalToolDefinition, ExternalToolProviderIdentity, ExternalToolProviderSnapshot, + ExternalToolSourceProvider, ExternalWatchRoot, PreparedExternalToolTarget, ProviderId, + SourceQualifiedToolTargetId, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +struct ToolProviderGeneration { + provider: Arc, + identity: ExternalToolProviderIdentity, + initial_result_received: bool, + last_success: Option, + last_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalToolCoordinatorSnapshot { + pub generation: u64, + pub discovery_pending: bool, + pub sources: Vec, + pub tools: Vec, + pub diagnostics: Vec, +} + +pub struct ExternalToolDiscoveryRequest { + provider_id: ProviderId, + provider: Arc, + context: ExternalSourceContext, +} + +impl ExternalToolDiscoveryRequest { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn execute(self) -> ExternalToolDiscoveryResult { + ExternalToolDiscoveryResult { + provider_id: self.provider_id, + candidate: self.provider.discover(&self.context), + } + } +} + +#[derive(Clone)] +pub struct ExternalToolDiscoveryResult { + provider_id: ProviderId, + candidate: Result, +} + +impl ExternalToolDiscoveryResult { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self { + Self { + provider_id, + candidate: Err(error), + } + } +} + +pub struct ExternalToolCoordinator { + context: ExternalSourceContext, + providers: Vec, + suppressed_sources: BTreeSet, + generation: u64, + snapshot: ExternalToolCoordinatorSnapshot, +} + +impl fmt::Debug for ExternalToolCoordinator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalToolCoordinator") + .field("context", &self.context) + .field("providers", &self.providers.len()) + .field("suppressed_sources", &self.suppressed_sources) + .field("generation", &self.generation) + .finish() + } +} + +impl ExternalToolCoordinator { + pub fn new( + context: ExternalSourceContext, + providers: Vec>, + ) -> Result { + let mut provider_ids = BTreeSet::new(); + let mut generations = Vec::with_capacity(providers.len()); + for provider in providers { + let identity = provider.identity(); + if !provider_ids.insert(identity.provider_id.clone()) { + return Err(format!( + "duplicate external tool provider id: {}", + identity.provider_id + )); + } + generations.push(ToolProviderGeneration { + provider, + identity, + initial_result_received: false, + last_success: None, + last_error: None, + }); + } + let discovery_pending = !generations.is_empty(); + Ok(Self { + context, + providers: generations, + suppressed_sources: BTreeSet::new(), + generation: 0, + snapshot: ExternalToolCoordinatorSnapshot { + generation: 0, + discovery_pending, + sources: Vec::new(), + tools: Vec::new(), + diagnostics: Vec::new(), + }, + }) + } + + pub fn refresh(&mut self) -> ExternalToolCoordinatorSnapshot { + let results = self + .discovery_requests() + .into_iter() + .map(ExternalToolDiscoveryRequest::execute) + .collect(); + self.apply_discovery_results(results) + } + + pub fn discovery_requests(&self) -> Vec { + self.providers + .iter() + .map(|generation| ExternalToolDiscoveryRequest { + provider_id: generation.identity.provider_id.clone(), + provider: generation.provider.clone(), + context: self.context.clone(), + }) + .collect() + } + + pub fn apply_discovery_results( + &mut self, + results: Vec, + ) -> ExternalToolCoordinatorSnapshot { + let mut results = results + .into_iter() + .map(|result| (result.provider_id, result.candidate)) + .collect::>(); + for generation in &mut self.providers { + let candidate = results + .remove(&generation.identity.provider_id) + .unwrap_or_else(|| { + Err(ExternalSourceProviderError::new( + "external_tool.discovery_result_missing", + "tool provider discovery did not return a result", + true, + )) + }); + apply_tool_provider_candidate(generation, candidate); + } + self.rebuild_snapshot() + } + + pub fn apply_discovery_result( + &mut self, + result: ExternalToolDiscoveryResult, + ) -> ExternalToolCoordinatorSnapshot { + if let Some(generation) = self + .providers + .iter_mut() + .find(|generation| generation.identity.provider_id == result.provider_id) + { + apply_tool_provider_candidate(generation, result.candidate); + } + self.rebuild_snapshot() + } + + pub fn snapshot(&self) -> ExternalToolCoordinatorSnapshot { + self.snapshot.clone() + } + + pub fn set_source_enabled(&mut self, stable_key: &str, enabled: bool) -> Result<(), String> { + let known = self.providers.iter().any(|provider| { + provider.last_success.as_ref().is_some_and(|snapshot| { + snapshot + .sources + .iter() + .any(|source| source.preference_key() == stable_key) + }) + }); + if !known { + return Err(format!("unknown external tool source: {stable_key}")); + } + if enabled { + self.suppressed_sources.remove(stable_key); + } else { + self.suppressed_sources.insert(stable_key.to_string()); + } + self.rebuild_snapshot(); + Ok(()) + } + + pub fn replace_suppressed_sources(&mut self, sources: BTreeSet) { + self.suppressed_sources = sources; + self.rebuild_snapshot(); + } + + pub fn suppressed_sources(&self) -> &BTreeSet { + &self.suppressed_sources + } + + pub fn prepare_target_guarded( + &self, + target_id: &SourceQualifiedToolTargetId, + expected_content_version: &str, + ) -> Result { + let current = self.snapshot.tools.iter().any(|tool| { + &tool.id.target == target_id && tool.content_version == expected_content_version + }); + if !current { + return Err(ExternalSourceProviderError::new( + "external_tool.stale_revision", + "tool target is not available at the requested revision", + true, + )); + } + let provider = self + .providers + .iter() + .find(|provider| provider.identity.provider_id == target_id.source.provider_id) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "external_tool.provider_missing", + "tool provider is not registered", + false, + ) + })?; + let prepared = + provider + .provider + .prepare_target(&self.context, target_id, expected_content_version)?; + if prepared.target_id != *target_id || prepared.content_version != expected_content_version + { + return Err(ExternalSourceProviderError::new( + "external_tool.prepared_target_mismatch", + "tool provider prepared a different target revision", + false, + )); + } + Ok(prepared) + } + + pub fn watch_roots(&self) -> Vec { + let mut roots = BTreeMap::new(); + for provider in &self.providers { + for root in provider.provider.watch_roots(&self.context) { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } + + fn rebuild_snapshot(&mut self) -> ExternalToolCoordinatorSnapshot { + self.generation = self.generation.saturating_add(1); + let mut sources = Vec::new(); + let mut tools = Vec::new(); + let mut diagnostics = Vec::new(); + for provider in &self.providers { + let failed = provider.last_error.is_some(); + if let Some(snapshot) = &provider.last_success { + for source in &snapshot.sources { + let suppressed = self.suppressed_sources.contains(&source.preference_key()); + sources.push(ExternalSourceCatalogEntry { + stable_key: source.preference_key(), + record: source.clone(), + lifecycle: if suppressed { + ExternalSourceLifecycleState::Suppressed + } else if failed { + ExternalSourceLifecycleState::Unavailable + } else { + ExternalSourceLifecycleState::Available + }, + }); + if !suppressed && !failed { + tools.extend( + snapshot + .tools + .iter() + .filter(|tool| tool.id.target.source == source.key) + .cloned(), + ); + } + } + diagnostics.extend(snapshot.diagnostics.clone()); + } + if let Some(error) = &provider.last_error { + diagnostics.push(ExternalSourceDiagnostic { + severity: if error.transient { + ExternalSourceDiagnosticSeverity::Warning + } else { + ExternalSourceDiagnosticSeverity::Error + }, + code: error.code.clone(), + message: error.message.clone(), + source: None, + }); + } + } + sources.sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + tools.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + self.snapshot = ExternalToolCoordinatorSnapshot { + generation: self.generation, + discovery_pending: self + .providers + .iter() + .any(|provider| !provider.initial_result_received), + sources, + tools, + diagnostics, + }; + self.snapshot.clone() + } +} + +fn apply_tool_provider_candidate( + generation: &mut ToolProviderGeneration, + candidate: Result, +) { + generation.initial_result_received = true; + match candidate { + Ok(snapshot) => match snapshot.validate() { + Ok(()) if snapshot.provider == generation.identity => { + generation.last_success = Some(snapshot); + generation.last_error = None; + } + Ok(()) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_tool.provider_identity_mismatch", + "tool provider returned a mismatched identity", + false, + )); + } + Err(error) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_tool.snapshot_invalid", + error.to_string(), + false, + )); + } + }, + Err(error) => generation.last_error = Some(error), + } +} diff --git a/src/crates/assembly/external-sources/tests/tool_coordinator_contracts.rs b/src/crates/assembly/external-sources/tests/tool_coordinator_contracts.rs new file mode 100644 index 0000000000..7d6eeb48d8 --- /dev/null +++ b/src/crates/assembly/external-sources/tests/tool_coordinator_contracts.rs @@ -0,0 +1,163 @@ +use bitfun_external_sources::ExternalToolCoordinator; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExternalSourceContext, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, + ExternalToolDefinition, ExternalToolProviderIdentity, ExternalToolProviderSnapshot, + ExternalToolRuntimeKind, ExternalToolSourceProvider, ExternalToolStaticStatus, + ExternalWatchRoot, PreparedExternalToolExport, PreparedExternalToolTarget, SourceKey, + SourceQualifiedToolId, SourceQualifiedToolTargetId, +}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +struct FakeToolProvider { + identity: ExternalToolProviderIdentity, + snapshot: Mutex>, +} + +impl FakeToolProvider { + fn new() -> Self { + let identity = ExternalToolProviderIdentity::new("fake.tools", "fake", "Fake").unwrap(); + Self { + identity: identity.clone(), + snapshot: Mutex::new(Ok(snapshot(identity, "v1"))), + } + } + + fn set_snapshot( + &self, + snapshot: Result, + ) { + *self.snapshot.lock().unwrap() = snapshot; + } +} + +impl ExternalToolSourceProvider for FakeToolProvider { + fn identity(&self) -> ExternalToolProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + self.snapshot.lock().unwrap().clone() + } + + fn prepare_target( + &self, + _context: &ExternalSourceContext, + target_id: &SourceQualifiedToolTargetId, + expected_content_version: &str, + ) -> Result { + if expected_content_version != "v1" { + return Err(ExternalSourceProviderError::new("stale", "stale", true)); + } + Ok(PreparedExternalToolTarget { + target_id: target_id.clone(), + content_version: expected_content_version.to_string(), + module_source: "export default {}".to_string(), + module_url: "file:///fake.js".to_string(), + working_directory: "/workspace".to_string(), + worktree_root: Some("/workspace".to_string()), + expected_tools: vec![PreparedExternalToolExport { + export_name: "default".to_string(), + tool_name: "weather".to_string(), + }], + }) + } + + fn watch_roots(&self, _context: &ExternalSourceContext) -> Vec { + vec![ExternalWatchRoot { + path: PathBuf::from("/workspace/.fake/tools"), + recursive: true, + }] + } +} + +fn snapshot(identity: ExternalToolProviderIdentity, version: &str) -> ExternalToolProviderSnapshot { + let source_key = SourceKey::new("fake.tools", "project").unwrap(); + let target = SourceQualifiedToolTargetId::new(source_key.clone(), "weather.js").unwrap(); + ExternalToolProviderSnapshot { + provider: identity, + sources: vec![ExternalSourceRecord { + key: source_key, + ecosystem_id: EcosystemId::new("fake").unwrap(), + display_name: "Fake project tools".to_string(), + source_kind: "standalone_tools".to_string(), + scope: ExternalSourceScope::Project, + location: "/workspace/.fake/tools".to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Available, + content_version: version.to_string(), + diagnostics: Vec::new(), + }], + tools: vec![ExternalToolDefinition { + id: SourceQualifiedToolId::new(target, "default").unwrap(), + name: "weather".to_string(), + description_preview: "Weather".to_string(), + module_path: "/workspace/.fake/tools/weather.js".to_string(), + working_directory: "/workspace".to_string(), + runtime_kind: ExternalToolRuntimeKind::JavaScript, + capabilities: vec![ExternalToolCapability::Network], + content_version: version.to_string(), + static_status: ExternalToolStaticStatus::Ready, + }], + diagnostics: Vec::new(), + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn tool_discovery_is_pending_until_each_provider_reports() { + let provider = Arc::new(FakeToolProvider::new()); + let coordinator = ExternalToolCoordinator::new(context(), vec![provider]).unwrap(); + assert!(coordinator.snapshot().discovery_pending); + assert!(coordinator.snapshot().tools.is_empty()); +} + +#[test] +fn provider_failure_withdraws_executable_candidates_instead_of_serving_stale_code() { + let provider = Arc::new(FakeToolProvider::new()); + let mut coordinator = ExternalToolCoordinator::new(context(), vec![provider.clone()]).unwrap(); + assert_eq!(coordinator.refresh().tools.len(), 1); + + provider.set_snapshot(Err(ExternalSourceProviderError::new( + "fake.read_failed", + "read failed", + true, + ))); + let failed = coordinator.refresh(); + + assert!(failed.tools.is_empty()); + assert!(failed + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "fake.read_failed")); +} + +#[test] +fn suppression_and_stale_preparation_are_guarded_by_the_coordinator() { + let provider = Arc::new(FakeToolProvider::new()); + let mut coordinator = ExternalToolCoordinator::new(context(), vec![provider]).unwrap(); + let snapshot = coordinator.refresh(); + let source_key = snapshot.sources[0].record.preference_key(); + let definition = snapshot.tools[0].clone(); + + coordinator.set_source_enabled(&source_key, false).unwrap(); + assert!(coordinator.snapshot().tools.is_empty()); + coordinator.set_source_enabled(&source_key, true).unwrap(); + + coordinator + .prepare_target_guarded(&definition.id.target, "v1") + .unwrap(); + assert!(coordinator + .prepare_target_guarded(&definition.id.target, "v0") + .is_err()); +} diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index aa9a5313bd..c61163c245 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -11,6 +11,7 @@ use std::fmt; use std::path::PathBuf; const MAX_ID_LENGTH: usize = 160; +const MAX_TOOL_NAME_LENGTH: usize = 64; const MAX_TEXT_LENGTH: usize = 4096; fn validate_id(value: &str, label: &'static str) -> Result<(), ExternalSourceContractError> { @@ -79,6 +80,8 @@ open_id!(ExecutionDomainId, "execution domain"); open_id!(ProviderId, "provider"); open_id!(SourceId, "source"); open_id!(CommandLocalId, "command"); +open_id!(ToolTargetLocalId, "tool target"); +open_id!(ToolExportLocalId, "tool export"); #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -137,6 +140,240 @@ impl SourceQualifiedCommandId { } } +fn validate_tool_name(value: &str) -> Result<(), ExternalSourceContractError> { + if value.is_empty() + || value.len() > MAX_TOOL_NAME_LENGTH + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(ExternalSourceContractError::InvalidIdentifier("tool name")); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceQualifiedToolTargetId { + pub source: SourceKey, + pub local_id: ToolTargetLocalId, +} + +impl SourceQualifiedToolTargetId { + pub fn new( + source: SourceKey, + local_id: impl Into, + ) -> Result { + Ok(Self { + source, + local_id: ToolTargetLocalId::new(local_id)?, + }) + } + + pub fn stable_key(&self) -> String { + format!( + "{}{}:{}", + self.source.stable_key(), + self.local_id.as_str().len(), + self.local_id + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceQualifiedToolId { + pub target: SourceQualifiedToolTargetId, + pub export_id: ToolExportLocalId, +} + +impl SourceQualifiedToolId { + pub fn new( + target: SourceQualifiedToolTargetId, + export_id: impl Into, + ) -> Result { + Ok(Self { + target, + export_id: ToolExportLocalId::new(export_id)?, + }) + } + + pub fn stable_key(&self) -> String { + format!( + "{}{}:{}", + self.target.stable_key(), + self.export_id.as_str().len(), + self.export_id + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalToolRuntimeKind { + JavaScript, + TypeScript, +} + +impl ExternalToolRuntimeKind { + pub fn as_str(self) -> &'static str { + match self { + Self::JavaScript => "java_script", + Self::TypeScript => "type_script", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalToolCapability { + FileSystem, + Network, + Process, + Environment, +} + +impl ExternalToolCapability { + pub fn as_str(self) -> &'static str { + match self { + Self::FileSystem => "file_system", + Self::Network => "network", + Self::Process => "process", + Self::Environment => "environment", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalToolStaticStatus { + Ready, + Unsupported { reason: String }, + Invalid { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolDefinition { + pub id: SourceQualifiedToolId, + pub name: String, + pub description_preview: String, + pub module_path: String, + pub working_directory: String, + pub runtime_kind: ExternalToolRuntimeKind, + pub capabilities: Vec, + pub content_version: String, + pub static_status: ExternalToolStaticStatus, +} + +impl ExternalToolDefinition { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_tool_name(&self.name)?; + if !self.description_preview.is_empty() { + validate_text(&self.description_preview, "tool description preview")?; + } + validate_text(&self.module_path, "tool module path")?; + validate_text(&self.working_directory, "tool working directory")?; + validate_id(&self.content_version, "tool content version")?; + let mut capabilities = BTreeSet::new(); + if self + .capabilities + .iter() + .any(|capability| !capabilities.insert(*capability)) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "tool capability", + )); + } + Ok(()) + } + + pub fn candidate_id(&self) -> String { + format!("external:{}", self.id.stable_key()) + } +} + +fn stable_fingerprint<'a>(parts: impl IntoIterator) -> String { + let mut first = 0xcbf29ce484222325_u64; + let mut second = 0x84222325cbf29ce4_u64; + for part in parts { + for byte in part.iter().copied().chain([0]) { + first ^= u64::from(byte); + first = first.wrapping_mul(0x100000001b3); + second ^= u64::from(byte); + second = second.wrapping_mul(0x9e3779b185ebca87); + } + } + format!("{first:016x}{second:016x}") +} + +/// Approval follows a target, execution domain, runtime, and disclosed +/// capability set. A content-only update therefore keeps prior approval while +/// capability expansion requires a new decision. +pub fn external_tool_approval_key( + execution_domain_id: &str, + target: &SourceQualifiedToolTargetId, + runtime_kind: ExternalToolRuntimeKind, + capabilities: impl IntoIterator, +) -> String { + let mut capabilities = capabilities.into_iter().collect::>(); + capabilities.sort_unstable(); + capabilities.dedup(); + let target_key = target.stable_key(); + let runtime = runtime_kind.as_str(); + let capability_names = capabilities + .into_iter() + .map(ExternalToolCapability::as_str) + .collect::>(); + let mut parts = vec![ + execution_domain_id.as_bytes(), + target_key.as_bytes(), + runtime.as_bytes(), + ]; + parts.extend(capability_names.iter().map(|value| value.as_bytes())); + format!( + "external_tool_approval:{}:{}", + execution_domain_id, + stable_fingerprint(parts) + ) +} + +/// Builds a version-sensitive fingerprint for local, MCP, and external tool +/// candidates. Candidate ordering does not affect the result. +pub fn external_tool_conflict_key<'a>( + execution_domain_id: &str, + tool_name: &str, + candidates: impl IntoIterator, +) -> String { + let mut candidates = candidates.into_iter().collect::>(); + candidates.sort_unstable(); + // Registry tool names are case-sensitive; case-only variants must not + // share a preference lineage or overwrite one another's decision. + let normalized_name = tool_name; + let encoded = candidates + .into_iter() + .map(|(id, version)| format!("{}:{id}{}:{version}", id.len(), version.len())) + .collect::>(); + let mut parts = vec![execution_domain_id.as_bytes(), normalized_name.as_bytes()]; + parts.extend(encoded.iter().map(|value| value.as_bytes())); + format!( + "external_tool:{}:{}:{}", + execution_domain_id, + normalized_name, + stable_fingerprint(parts) + ) +} + +pub fn external_tool_decision_key(approval_key: &str, content_version: &str) -> String { + format!( + "external_tool_decision:{}", + stable_fingerprint([approval_key.as_bytes(), content_version.as_bytes()]) + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -448,6 +685,110 @@ pub trait PromptCommandSourceProvider: Send + Sync { fn watch_roots(&self, context: &ExternalSourceContext) -> Vec; } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolProviderIdentity { + pub provider_id: ProviderId, + pub ecosystem_id: EcosystemId, + pub display_name: String, +} + +impl ExternalToolProviderIdentity { + pub fn new( + provider_id: impl Into, + ecosystem_id: impl Into, + display_name: impl Into, + ) -> Result { + let display_name = display_name.into(); + validate_text(&display_name, "tool provider display name")?; + Ok(Self { + provider_id: ProviderId::new(provider_id)?, + ecosystem_id: EcosystemId::new(ecosystem_id)?, + display_name, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolProviderSnapshot { + pub provider: ExternalToolProviderIdentity, + pub sources: Vec, + pub tools: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl ExternalToolProviderSnapshot { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + let mut source_keys = BTreeSet::new(); + for source in &self.sources { + source.validate()?; + if source.key.provider_id != self.provider.provider_id + || source.ecosystem_id != self.provider.ecosystem_id + || !source_keys.insert(source.key.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "tool provider-qualified source", + )); + } + } + let mut tool_ids = BTreeSet::new(); + for tool in &self.tools { + tool.validate()?; + if tool.id.target.source.provider_id != self.provider.provider_id + || !source_keys.contains(&tool.id.target.source) + || !tool_ids.insert(tool.id.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "provider-qualified tool", + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedExternalToolExport { + pub export_name: String, + pub tool_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedExternalToolTarget { + pub target_id: SourceQualifiedToolTargetId, + pub content_version: String, + pub module_source: String, + pub module_url: String, + pub working_directory: String, + /// Optional VCS/worktree root supplied by the ecosystem adapter. The + /// generic runtime forwards it without deriving ecosystem-specific paths. + pub worktree_root: Option, + pub expected_tools: Vec, +} + +/// Capability-specific tool provider implemented independently by each +/// external ecosystem adapter. Discovery is static; executable preparation is +/// called only after product approval. +pub trait ExternalToolSourceProvider: Send + Sync { + fn identity(&self) -> ExternalToolProviderIdentity; + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result; + + fn prepare_target( + &self, + context: &ExternalSourceContext, + target_id: &SourceQualifiedToolTargetId, + expected_content_version: &str, + ) -> Result; + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec; +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -475,6 +816,77 @@ pub struct PromptCommandCatalogEntry { pub definition: PromptCommandDefinition, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalToolActivationState { + ApprovalRequired, + Disabled, + Active, + Conflict, + Unsupported { reason: String }, + RuntimeUnavailable { reason: String }, + LoadFailed { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolCatalogEntry { + pub definition: ExternalToolDefinition, + pub approval_key: String, + pub decision_key: String, + pub activation: ExternalToolActivationState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolApprovalRequest { + pub approval_key: String, + pub decision_key: String, + pub target_id: SourceQualifiedToolTargetId, + pub source_display_name: String, + pub source_scope: ExternalSourceScope, + pub source_location: String, + pub working_directory: String, + pub runtime_kind: ExternalToolRuntimeKind, + pub capabilities: Vec, + pub content_version: String, + pub tool_names: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalToolConflictCandidateKind { + BuiltIn, + Mcp, + External, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolConflictCandidate { + pub candidate_id: String, + pub display_name: String, + pub kind: ExternalToolConflictCandidateKind, + pub provider_id: String, + pub content_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_location: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolConflict { + pub conflict_key: String, + pub tool_name: String, + pub candidates: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_candidate_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PromptCommandConflictCandidate { @@ -544,5 +956,11 @@ pub struct ExternalSourceCatalogSnapshot { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub command_conflicts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_approval_requests: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } 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 36fd83dfb3..55137766a4 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -1,10 +1,12 @@ use bitfun_product_domains::external_sources::{ - prompt_command_conflict_key, EcosystemId, ExecutionDomainId, ExpandedPromptCommand, - ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, - ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, + external_tool_approval_key, external_tool_conflict_key, prompt_command_conflict_key, + EcosystemId, ExecutionDomainId, ExpandedPromptCommand, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, + ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, + ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, PromptCommandAvailability, PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, PromptCommandSourceProvider, SourceKey, - SourceQualifiedCommandId, + SourceQualifiedCommandId, SourceQualifiedToolId, SourceQualifiedToolTargetId, }; use std::path::PathBuf; @@ -215,3 +217,140 @@ fn unavailable_command_must_be_unique_absent_and_source_qualified() { .push(invalid.commands[0].id.clone()); assert!(invalid.validate().is_err()); } + +#[test] +fn standalone_tool_contract_separates_static_preview_from_executable_source() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("opencode.tools", "project-tools").unwrap(), + "weather.js", + ) + .unwrap(); + let tool = ExternalToolDefinition { + id: SourceQualifiedToolId::new(target, "default").unwrap(), + name: "weather".to_string(), + description_preview: "Get the weather for a location".to_string(), + module_path: "/workspace/.opencode/tools/weather.js".to_string(), + working_directory: "/workspace".to_string(), + runtime_kind: ExternalToolRuntimeKind::JavaScript, + capabilities: vec![ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ExternalToolCapability::Process, + ], + content_version: "sha256:v1".to_string(), + static_status: ExternalToolStaticStatus::Ready, + }; + + let encoded = serde_json::to_value(&tool).expect("serialize tool preview"); + assert_eq!(encoded["name"], "weather"); + assert_eq!(encoded["runtimeKind"], "java_script"); + assert!(encoded.get("moduleSource").is_none()); + assert!(encoded.get("payload").is_none()); + tool.validate().expect("valid standalone tool preview"); +} + +#[test] +fn standalone_tool_contract_rejects_names_that_are_not_model_callable() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("fake.tools", "project-tools").unwrap(), + "unsafe.js", + ) + .unwrap(); + let mut tool = ExternalToolDefinition { + id: SourceQualifiedToolId::new(target, "default").unwrap(), + name: "unsafe tool".to_string(), + description_preview: String::new(), + module_path: "/workspace/unsafe.js".to_string(), + working_directory: "/workspace".to_string(), + runtime_kind: ExternalToolRuntimeKind::JavaScript, + capabilities: vec![ExternalToolCapability::FileSystem], + content_version: "sha256:v1".to_string(), + static_status: ExternalToolStaticStatus::Ready, + }; + + assert!(tool.validate().is_err()); + tool.name = "safe_tool-1".to_string(); + tool.validate() + .expect("portable tool name should be accepted"); +} + +#[test] +fn tool_approval_is_stable_for_safe_updates_but_changes_with_capabilities_or_domain() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("opencode.tools", "project-tools").unwrap(), + "weather.js", + ) + .unwrap(); + let first = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ], + ); + let reordered = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::Network, + ExternalToolCapability::FileSystem, + ], + ); + let expanded = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ExternalToolCapability::Process, + ], + ); + let remote = external_tool_approval_key( + "remote-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ], + ); + + assert_eq!(first, reordered); + assert_ne!(first, expanded); + assert_ne!(first, remote); +} + +#[test] +fn tool_conflict_choice_is_invalidated_when_name_or_candidate_changes() { + let first = external_tool_conflict_key( + "local-user", + "weather", + [ + ("builtin:weather", "builtin-v1"), + ("opencode:weather", "tool-v1"), + ], + ); + let reordered = external_tool_conflict_key( + "local-user", + "WEATHER", + [ + ("opencode:weather", "tool-v1"), + ("builtin:weather", "builtin-v1"), + ], + ); + let updated = external_tool_conflict_key( + "local-user", + "weather", + [ + ("builtin:weather", "builtin-v1"), + ("opencode:weather", "tool-v2"), + ], + ); + + assert_ne!(first, reordered); + assert_ne!(first, updated); +} diff --git a/src/crates/contracts/runtime-ports/AGENTS.md b/src/crates/contracts/runtime-ports/AGENTS.md index 89f7fb56cc..a9f1aed934 100644 --- a/src/crates/contracts/runtime-ports/AGENTS.md +++ b/src/crates/contracts/runtime-ports/AGENTS.md @@ -19,6 +19,10 @@ facts. It is an interface crate, not a runtime implementation crate. - `SessionStorePort` owns typed session storage-path resolution plus restore / load request and timing facts only. Concrete session persistence, file IO, session lifecycle, context restore, and prompt assembly do not belong here. +- `ScriptToolRuntime` owns only provider-neutral availability, versioned + load/invoke/cancel/dispose requests, execution context paths, and string + results. Ecosystem source parsing, approval/conflict policy, product routing, + process supervision, dependency installation, and UI do not belong here. - Do not put filesystem writes, process execution, network clients, Git/AI/MCP concrete behavior, product policy, permission decisions, audit outcomes, UI extension behavior, UI implementation, or UI command logic here. diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 7a9df667df..7eea6ec17d 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -12,6 +12,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; mod plugin; +mod script_tool; pub use plugin::{ validate_plugin_dispatch_response, validate_plugin_runtime_read_response, DisabledPluginRuntimeClient, ExtensionCapabilityAvailability, PermissionPromptDenyState, @@ -28,6 +29,11 @@ pub use plugin::{ PluginRuntimeUnavailableReason, PluginSourceKind, PluginSourceRef, PluginStatusKind, PluginStatusSnapshot, PluginTargetRef, PluginTrustLevel, ProjectionOnlyPluginRuntimeClient, }; +pub use script_tool::{ + ScriptToolDescriptor, ScriptToolExpectedExport, ScriptToolInvokeRequest, + ScriptToolInvokeResponse, ScriptToolLoadRequest, ScriptToolLoadResponse, ScriptToolRuntime, + ScriptToolRuntimeAvailability, +}; pub type PortResult = Result; diff --git a/src/crates/contracts/runtime-ports/src/script_tool.rs b/src/crates/contracts/runtime-ports/src/script_tool.rs new file mode 100644 index 0000000000..0d062734c0 --- /dev/null +++ b/src/crates/contracts/runtime-ports/src/script_tool.rs @@ -0,0 +1,93 @@ +//! Provider-neutral process boundary for executable script tools. + +use crate::PortResult; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ScriptToolRuntimeAvailability { + Available { executable: String, version: String }, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScriptToolLoadRequest { + pub target_id: String, + pub revision: String, + pub module_source: String, + pub module_url: String, + pub working_directory: String, + pub expected_tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScriptToolExpectedExport { + pub export_name: String, + pub tool_name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScriptToolDescriptor { + pub export_name: String, + pub name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScriptToolLoadResponse { + pub target_id: String, + pub revision: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScriptToolInvokeRequest { + pub target_id: String, + pub revision: String, + pub export_name: String, + pub operation_id: String, + pub arguments: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScriptToolInvokeResponse { + pub output: String, +} + +#[async_trait] +pub trait ScriptToolRuntime: Send + Sync { + async fn availability(&self) -> ScriptToolRuntimeAvailability; + + async fn is_loaded(&self, target_id: &str) -> bool; + + /// Waits until the currently loaded target process exits. Implementations + /// must not report a replaced or explicitly disposed generation as an + /// unexpected exit for the new target. + async fn wait_until_unloaded(&self, target_id: &str) -> PortResult<()>; + + async fn load(&self, request: ScriptToolLoadRequest) -> PortResult; + + async fn invoke( + &self, + request: ScriptToolInvokeRequest, + ) -> PortResult; + + async fn cancel(&self, target_id: &str, operation_id: &str) -> PortResult<()>; + + async fn dispose(&self, target_id: &str) -> PortResult<()>; +} diff --git a/src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs new file mode 100644 index 0000000000..639fe1268d --- /dev/null +++ b/src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs @@ -0,0 +1,156 @@ +use async_trait::async_trait; +use bitfun_runtime_ports::{ + PortResult, ScriptToolDescriptor, ScriptToolExpectedExport, ScriptToolInvokeRequest, + ScriptToolInvokeResponse, ScriptToolLoadRequest, ScriptToolLoadResponse, ScriptToolRuntime, + ScriptToolRuntimeAvailability, +}; +use serde_json::json; +use std::sync::Mutex; + +#[derive(Default)] +struct FakeRuntime { + operations: Mutex>, + loaded: Mutex>, +} + +#[async_trait] +impl ScriptToolRuntime for FakeRuntime { + async fn availability(&self) -> ScriptToolRuntimeAvailability { + ScriptToolRuntimeAvailability::Available { + executable: "node".to_string(), + version: "v-test".to_string(), + } + } + + async fn is_loaded(&self, target_id: &str) -> bool { + self.loaded.lock().unwrap().as_deref() == Some(target_id) + } + + async fn wait_until_unloaded(&self, _target_id: &str) -> PortResult<()> { + Ok(()) + } + + async fn load(&self, request: ScriptToolLoadRequest) -> PortResult { + *self.loaded.lock().unwrap() = Some(request.target_id.clone()); + self.operations + .lock() + .unwrap() + .push(format!("load:{}", request.target_id)); + Ok(ScriptToolLoadResponse { + target_id: request.target_id, + revision: request.revision, + tools: vec![ScriptToolDescriptor { + export_name: "default".to_string(), + name: "weather".to_string(), + description: "Weather lookup".to_string(), + input_schema: json!({"type": "object"}), + }], + }) + } + + async fn invoke( + &self, + request: ScriptToolInvokeRequest, + ) -> PortResult { + self.operations + .lock() + .unwrap() + .push(format!("invoke:{}", request.operation_id)); + Ok(ScriptToolInvokeResponse { + output: "sunny".to_string(), + }) + } + + async fn cancel(&self, target_id: &str, operation_id: &str) -> PortResult<()> { + self.operations + .lock() + .unwrap() + .push(format!("cancel:{target_id}:{operation_id}")); + Ok(()) + } + + async fn dispose(&self, target_id: &str) -> PortResult<()> { + let mut loaded = self.loaded.lock().unwrap(); + if loaded.as_deref() == Some(target_id) { + *loaded = None; + } + drop(loaded); + self.operations + .lock() + .unwrap() + .push(format!("dispose:{target_id}")); + Ok(()) + } +} + +#[tokio::test] +async fn port_keeps_worker_lifecycle_provider_neutral() { + let runtime = FakeRuntime::default(); + let availability = runtime.availability().await; + assert!(matches!( + availability, + ScriptToolRuntimeAvailability::Available { .. } + )); + + let loaded = runtime + .load(ScriptToolLoadRequest { + target_id: "target-1".to_string(), + revision: "v1".to_string(), + module_source: "export default {}".to_string(), + module_url: "file:///workspace/tool.js".to_string(), + working_directory: "/workspace".to_string(), + expected_tools: vec![ScriptToolExpectedExport { + export_name: "default".to_string(), + tool_name: "weather".to_string(), + }], + }) + .await + .unwrap(); + assert_eq!(loaded.tools[0].name, "weather"); + + runtime + .invoke(ScriptToolInvokeRequest { + target_id: "target-1".to_string(), + revision: "v1".to_string(), + export_name: "default".to_string(), + operation_id: "operation-1".to_string(), + arguments: json!({"location": "Shanghai"}), + workspace_root: Some("/workspace".to_string()), + worktree_root: Some("/workspace".to_string()), + session_id: Some("session-1".to_string()), + }) + .await + .unwrap(); + runtime.cancel("target-1", "operation-1").await.unwrap(); + runtime.dispose("target-1").await.unwrap(); + + assert_eq!( + runtime.operations.into_inner().unwrap(), + vec![ + "load:target-1", + "invoke:operation-1", + "cancel:target-1:operation-1", + "dispose:target-1", + ] + ); +} + +#[test] +fn load_contract_carries_no_ecosystem_specific_fields() { + let value = serde_json::to_value(ScriptToolLoadRequest { + target_id: "target-1".to_string(), + revision: "v1".to_string(), + module_source: "export default {}".to_string(), + module_url: "file:///workspace/tool.js".to_string(), + working_directory: "/workspace".to_string(), + expected_tools: vec![ScriptToolExpectedExport { + export_name: "default".to_string(), + tool_name: "weather".to_string(), + }], + }) + .unwrap(); + + assert_eq!(value["targetId"], "target-1"); + assert!(value.get("ecosystem").is_none()); + assert!(value.get("opencode").is_none()); +} diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index 356bca7f4b..12191c0db9 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -1553,6 +1553,18 @@ impl ToolRegistry { count } + /// Remove exactly one registry entry and return the decorated tool that was + /// active under that name. This is used by contextual compatibility + /// routers that must preserve, rather than silently discard, a displaced + /// built-in or dynamic provider. + pub fn unregister_tool(&mut self, name: &str) -> Option> { + let removed = self.tools.shift_remove(name)?; + self.dynamic_tools.shift_remove(name); + self.static_tool_providers.shift_remove(name); + self.snapshot_generation = self.snapshot_generation.saturating_add(1); + Some(removed) + } + pub fn get_tool(&self, name: &str) -> Option> { self.tools.get(name).cloned() } diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index f897b2a8b9..0ef8960f90 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -64,6 +64,14 @@ slices that are outside pure product logic but still platform-neutral. trust-file locking, and atomic persistence. Product path selection stays in assembly; ecosystem parsing and Plugin Runtime Host behavior stay in their adapter and execution owners. +- Script-tool runtime integration owns provider-neutral process supervision, + bounded framing/output, target load/invoke/cancel/dispose, timeout, and worker + health behind `script-tool-runtime`. It must not parse OpenCode source paths, + 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. - 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 4a8aece06b..1b3eee3548 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -205,6 +205,7 @@ workspace-search = [ "thiserror", "which", ] +script-tool-runtime = ["async-trait", "bitfun-runtime-ports", "which"] web-tools = ["reqwest", "thiserror"] product-full = [ "announcement", @@ -222,6 +223,7 @@ product-full = [ "remote-ssh", "remote-ssh-concrete", "review-platform", + "script-tool-runtime", "web-tools", "workspace-search", ] @@ -234,5 +236,9 @@ tempfile = { workspace = true } name = "debug_log_owner_contracts" required-features = ["debug-log"] +[[test]] +name = "script_tool_runtime" +required-features = ["script-tool-runtime"] + [lints] workspace = true diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 7a76f38766..690d46721c 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -48,6 +48,9 @@ pub mod review_platform; #[cfg(feature = "review-platform")] pub(crate) mod review_platform_http; +#[cfg(feature = "script-tool-runtime")] +pub mod script_tool; + #[cfg(feature = "workspace-search")] pub mod workspace_search; diff --git a/src/crates/services/services-integrations/src/script_tool.rs b/src/crates/services/services-integrations/src/script_tool.rs new file mode 100644 index 0000000000..057aff1026 --- /dev/null +++ b/src/crates/services/services-integrations/src/script_tool.rs @@ -0,0 +1,976 @@ +//! Local process owner for provider-neutral JavaScript tool workers. + +use async_trait::async_trait; +use bitfun_runtime_ports::{ + PortError, PortErrorKind, PortResult, ScriptToolInvokeRequest, ScriptToolInvokeResponse, + ScriptToolLoadRequest, ScriptToolLoadResponse, ScriptToolRuntime, + ScriptToolRuntimeAvailability, +}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::collections::HashMap; +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::sync::{oneshot, watch, Mutex, OwnedSemaphorePermit, RwLock, Semaphore}; + +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const CANCEL_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(500); +const MAX_PROTOCOL_FRAME_BYTES: usize = 8 * 1024 * 1024; + +// The wrapper creates `responseToken` before evaluating the target, keeps it out +// of normal request input and uses it to reject accidental stdout collisions. +// The target module runs in a separate VM realm to avoid accidental mutation of +// common wrapper globals. Neither mechanism authenticates a hostile module: +// Node VM contexts are not a security boundary, and approved code still controls +// its target process with the disclosed filesystem, network, child-process and +// environment capabilities under the current user. +const WORKER_SOURCE: &str = r#" +import readline from "node:readline"; +import vm from "node:vm"; +import { randomUUID } from "node:crypto"; + +const parse = JSON.parse.bind(JSON); +const stringify = JSON.stringify.bind(JSON); +const protocolWrite = process.stdout.write.bind(process.stdout); +const reallyExit = process.reallyExit.bind(process); +const trustedSetTimeout = setTimeout; +const responseToken = randomUUID(); +const maxOutputBytes = 1024 * 1024; +const targets = new Map(); +const operations = new Map(); + +function write(value) { + protocolWrite(`${stringify(value)}\n`); +} + +write({ kind: "ready", token: responseToken }); + +function processFacade() { + const blockedControlModules = new Set([ + "inspector", "node:inspector", "module", "node:module", "process", "node:process", + "vm", "node:vm", "worker_threads", "node:worker_threads", + ]); + return Object.freeze({ + argv: Object.freeze([...process.argv]), + arch: process.arch, + env: process.env, + platform: process.platform, + version: process.version, + versions: process.versions, + cwd: process.cwd.bind(process), + chdir: process.chdir.bind(process), + exit: process.exit.bind(process), + getBuiltinModule(name) { + if (blockedControlModules.has(name)) { + throw new Error(`builtin module '${name}' is unavailable inside the script tool worker`); + } + return process.getBuiltinModule(name); + }, + }); +} + +function moduleContext() { + const stderrConsole = Object.freeze({ + debug: console.error.bind(console), + error: console.error.bind(console), + info: console.error.bind(console), + log: console.error.bind(console), + warn: console.error.bind(console), + }); + const sandbox = { + AbortController, + AbortSignal, + Blob, + Buffer, + clearImmediate, + clearInterval, + clearTimeout, + console: stderrConsole, + crypto: globalThis.crypto, + fetch: globalThis.fetch, + FormData: globalThis.FormData, + Headers: globalThis.Headers, + process: processFacade(), + queueMicrotask, + Request: globalThis.Request, + Response: globalThis.Response, + setImmediate, + setInterval, + setTimeout, + structuredClone, + TextDecoder, + TextEncoder, + URL, + URLSearchParams, + }; + sandbox.global = sandbox; + sandbox.globalThis = sandbox; + return vm.createContext(sandbox, { name: "bitfun-script-tool" }); +} + +function normalizeSchema(rawSchema, path) { + if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) { + throw Object.assign(new Error(`schema for '${path}' must be an object`), { code: "INVALID_REQUEST" }); + } + const schema = { ...rawSchema }; + const hasDefault = Object.hasOwn(schema, "__default") + || (Object.hasOwn(schema, "default") && typeof schema.default !== "function"); + const optional = schema.__optional === true || hasDefault; + if (Object.hasOwn(schema, "__default")) schema.default = schema.__default; + delete schema.__default; + delete schema.__optional; + for (const [name, value] of Object.entries(schema)) { + if (typeof value === "function") delete schema[name]; + } + + if (schema.type === "object") { + const rawProperties = schema.properties ?? {}; + if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) { + throw Object.assign(new Error(`schema properties for '${path}' must be an object`), { code: "INVALID_REQUEST" }); + } + const properties = Object.create(null); + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((name) => typeof name === "string" && Object.hasOwn(rawProperties, name)) + : [], + ); + for (const [name, child] of Object.entries(rawProperties)) { + const normalized = normalizeSchema(child, `${path}.${name}`); + properties[name] = normalized.schema; + if (normalized.optional) required.delete(name); + else required.add(name); + } + schema.properties = properties; + schema.required = [...required]; + } else if (schema.type === "array" && schema.items !== undefined) { + schema.items = normalizeSchema(schema.items, `${path}[]`).schema; + } + return { schema, optional }; +} + +function schemaForArgs(args) { + if (!args || typeof args !== "object" || Array.isArray(args)) { + throw Object.assign(new Error("tool args must be an object"), { code: "INVALID_REQUEST" }); + } + return normalizeSchema( + { type: "object", properties: args, additionalProperties: false }, + "args", + ).schema; +} + +function validationError(value, schema, path) { + if (!schema || typeof schema !== "object") return null; + if (Array.isArray(schema.enum) && !schema.enum.some((item) => stringify(item) === stringify(value))) { + return `${path} must be one of the declared enum values`; + } + switch (schema.type) { + case "object": { + if (!value || typeof value !== "object" || Array.isArray(value)) return `${path} must be an object`; + const properties = schema.properties ?? {}; + for (const required of schema.required ?? []) { + if (!Object.hasOwn(value, required)) return `${path}.${required} is required`; + } + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!Object.hasOwn(properties, key)) return `${path}.${key} is not allowed`; + } + } + for (const [key, child] of Object.entries(properties)) { + if (Object.hasOwn(value, key)) { + const error = validationError(value[key], child, `${path}.${key}`); + if (error) return error; + } + } + break; + } + case "array": + if (!Array.isArray(value)) return `${path} must be an array`; + for (let index = 0; index < value.length; index += 1) { + const error = validationError(value[index], schema.items, `${path}[${index}]`); + if (error) return error; + } + break; + case "string": if (typeof value !== "string") return `${path} must be a string`; break; + case "number": if (typeof value !== "number" || !Number.isFinite(value)) return `${path} must be a number`; break; + case "integer": if (!Number.isInteger(value)) return `${path} must be an integer`; break; + case "boolean": if (typeof value !== "boolean") return `${path} must be a boolean`; break; + default: break; + } + if (typeof schema.minLength === "number" && value.length < schema.minLength) return `${path} is too short`; + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return `${path} is too long`; + if (typeof schema.minItems === "number" && value.length < schema.minItems) return `${path} has too few items`; + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return `${path} has too many items`; + if (typeof schema.minimum === "number" && value < schema.minimum) return `${path} is below minimum`; + if (typeof schema.maximum === "number" && value > schema.maximum) return `${path} is above maximum`; + return null; +} + +function materializeDefaults(value, schema) { + if (!schema || typeof schema !== "object") return value; + if (schema.type === "object" && value && typeof value === "object" && !Array.isArray(value)) { + const result = { ...value }; + for (const [key, child] of Object.entries(schema.properties ?? {})) { + if (!Object.hasOwn(result, key) && Object.hasOwn(child, "default") && typeof child.default !== "function") { + result[key] = structuredClone(child.default); + } + if (Object.hasOwn(result, key)) result[key] = materializeDefaults(result[key], child); + } + return result; + } + if (schema.type === "array" && Array.isArray(value)) { + return value.map((item) => materializeDefaults(item, schema.items)); + } + return value; +} + +async function load(message) { + if (targets.has(message.targetId)) targets.delete(message.targetId); + const context = moduleContext(); + const module = new vm.SourceTextModule(message.moduleSource, { + context, + identifier: message.moduleUrl, + initializeImportMeta(meta) { meta.url = message.moduleUrl; }, + }); + await module.link((specifier) => { + throw Object.assign(new Error(`unsupported module import '${specifier}'`), { code: "INVALID_REQUEST" }); + }); + await module.evaluate(); + const tools = []; + const exportsByName = new Map(); + for (const expected of message.expectedTools) { + const definition = module.namespace[expected.exportName]; + if (!definition || typeof definition !== "object" || typeof definition.execute !== "function") { + throw Object.assign(new Error(`export '${expected.exportName}' is not a tool definition`), { code: "INVALID_REQUEST" }); + } + const inputSchema = schemaForArgs(definition.args ?? {}); + exportsByName.set(expected.exportName, { definition, inputSchema }); + tools.push({ + exportName: expected.exportName, + name: expected.toolName, + description: typeof definition.description === "string" ? definition.description : "", + inputSchema, + }); + } + targets.set(message.targetId, { revision: message.revision, exportsByName }); + return { targetId: message.targetId, revision: message.revision, tools }; +} + +async function invoke(message) { + const target = targets.get(message.targetId); + if (!target || target.revision !== message.revision) { + throw Object.assign(new Error("tool target is not loaded at the requested revision"), { code: "TARGET_NOT_FOUND" }); + } + const loadedExport = target.exportsByName.get(message.exportName); + if (!loadedExport) { + throw Object.assign(new Error("tool export is not loaded"), { code: "TARGET_NOT_FOUND" }); + } + if (operations.has(message.operationId)) { + throw Object.assign(new Error("tool operation id is already active"), { code: "INVALID_REQUEST" }); + } + const argumentsWithDefaults = materializeDefaults(message.arguments, loadedExport.inputSchema); + const inputError = validationError(argumentsWithDefaults, loadedExport.inputSchema, "arguments"); + if (inputError) { + throw Object.assign(new Error(inputError), { code: "INVALID_REQUEST" }); + } + const controller = new AbortController(); + operations.set(message.operationId, { targetId: message.targetId, controller }); + try { + const output = await loadedExport.definition.execute(argumentsWithDefaults, { + directory: message.workspaceRoot ?? process.cwd(), + worktree: message.worktreeRoot ?? message.workspaceRoot ?? process.cwd(), + sessionID: message.sessionId, + abort: controller.signal, + }); + const text = typeof output === "string" + ? output + : output && typeof output.output === "string" + ? output.output + : null; + if (text === null) { + throw Object.assign(new Error("external tools must return a string or an object with a string output"), { code: "INVALID_REQUEST" }); + } + if (Buffer.byteLength(text, "utf8") > maxOutputBytes) { + throw Object.assign(new Error("tool output exceeds the worker response limit"), { code: "INVALID_REQUEST" }); + } + return { output: text }; + } catch (error) { + if (controller.signal.aborted) error.workerKind = "cancelled"; + throw error; + } finally { + operations.delete(message.operationId); + } +} + +async function cancel(message) { + const operation = operations.get(message.operationId); + if (operation?.targetId !== message.targetId) return {}; + operation.controller.abort(); + while (operations.has(message.operationId)) { + await new Promise((resolve) => trustedSetTimeout(resolve, 10)); + } + return {}; +} + +async function dispose(message) { + for (const [operationId, operation] of operations) { + if (operation.targetId === message.targetId) operation.controller.abort(); + operations.delete(operationId); + } + targets.delete(message.targetId); + return {}; +} + +function errorKind(error, message) { + if (error?.workerKind === "cancelled") return "cancelled"; + const operation = message?.operationId ? operations.get(message.operationId) : null; + if (operation?.controller.signal.aborted) return "cancelled"; + if (error?.code === "TARGET_NOT_FOUND") return "not_found"; + if (error?.code === "INVALID_REQUEST") return "invalid_request"; + return "backend"; +} + +async function handle(message) { + switch (message.type) { + case "load": return load(message); + case "invoke": return invoke(message); + case "cancel": return cancel(message); + case "dispose": return dispose(message); + default: throw Object.assign(new Error("unknown worker request"), { code: "INVALID_REQUEST" }); + } +} + +async function respond(message) { + try { + const result = await handle(message); + write({ kind: "complete", token: responseToken, id: message.id, ok: true, result }); + } catch (error) { + write({ + kind: "complete", + token: responseToken, + id: message.id, + ok: false, + errorKind: errorKind(error, message), + error: error instanceof Error ? error.message.slice(0, 4096) : "script tool request failed", + }); + } + if (message.type === "dispose") reallyExit(0); +} + +const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on("line", (line) => { + let message; + try { + message = parse(line); + } catch { + reallyExit(1); + return; + } + void respond(message); +}); +"#; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkerFrame { + kind: String, + token: String, + #[serde(default)] + id: Option, + #[serde(default)] + ok: bool, + #[serde(default)] + result: Value, + #[serde(default)] + error_kind: Option, + #[serde(default)] + error: Option, +} + +struct PendingRequest { + sender: oneshot::Sender>, +} + +async fn read_protocol_frame( + reader: &mut BufReader, +) -> std::io::Result>> { + let mut frame = Vec::new(); + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + if frame.is_empty() { + return Ok(None); + } + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "script tool worker ended inside a protocol frame", + )); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.unwrap_or(available.len()); + if frame.len().saturating_add(take) > MAX_PROTOCOL_FRAME_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "script tool worker protocol frame exceeded the size limit", + )); + } + frame.extend_from_slice(&available[..take]); + reader.consume(take + usize::from(newline.is_some())); + if newline.is_some() { + return Ok(Some(frame)); + } + } +} + +struct NodeWorker { + stdin: Mutex, + child: Mutex, + pending: Arc>>, + next_request_id: AtomicU64, + response_token: String, + invoke_gate: Arc, + exit_state: watch::Receiver, +} + +impl NodeWorker { + async fn spawn(executable: &PathBuf, working_directory: &str) -> PortResult> { + let mut command = Command::new(executable); + command + .arg("--experimental-vm-modules") + .arg("--input-type=module") + .arg("--eval") + .arg(WORKER_SOURCE) + .current_dir(working_directory) + .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}"), + ) + })?; + let stdin = child + .stdin + .take() + .ok_or_else(|| PortError::new(PortErrorKind::Backend, "worker stdin is unavailable"))?; + let stdout = child.stdout.take().ok_or_else(|| { + PortError::new(PortErrorKind::Backend, "worker stdout is unavailable") + })?; + let stderr = child.stderr.take().ok_or_else(|| { + PortError::new(PortErrorKind::Backend, "worker stderr is unavailable") + })?; + let mut reader = BufReader::new(stdout); + let ready = tokio::time::timeout(REQUEST_TIMEOUT, read_protocol_frame(&mut reader)) + .await + .map_err(|_| { + PortError::new( + PortErrorKind::Timeout, + "script tool worker startup timed out", + ) + })? + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))? + .ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "script tool worker exited during startup", + ) + })?; + let ready = serde_json::from_slice::(&ready).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("script tool worker returned an invalid startup frame: {error}"), + ) + })?; + if ready.kind != "ready" || ready.token.len() < 16 || ready.token.len() > 128 { + return Err(PortError::new( + PortErrorKind::Backend, + "script tool worker returned an invalid startup token", + )); + } + let pending = Arc::new(Mutex::new(HashMap::new())); + let (exit_sender, exit_state) = watch::channel(false); + let worker = Arc::new(Self { + stdin: Mutex::new(stdin), + child: Mutex::new(child), + pending: pending.clone(), + next_request_id: AtomicU64::new(1), + response_token: ready.token, + invoke_gate: Arc::new(Semaphore::new(1)), + exit_state, + }); + + let weak_worker = Arc::downgrade(&worker); + let response_token = worker.response_token.clone(); + tokio::spawn(async move { + let mut rejected_frame_bytes = 0usize; + loop { + let frame = match read_protocol_frame(&mut reader).await { + Ok(Some(frame)) => frame, + Ok(None) => break, + Err(error) => { + log::warn!("Script tool worker protocol closed: {}", error); + if let Some(worker) = weak_worker.upgrade() { + let _ = worker.terminate().await; + } + break; + } + }; + let response = match serde_json::from_slice::(&frame) { + Ok(response) => response, + Err(_) => { + rejected_frame_bytes = rejected_frame_bytes.saturating_add(frame.len()); + if rejected_frame_bytes <= 1024 * 1024 { + continue; + } + log::warn!("Script tool worker exceeded the rejected stdout budget"); + if let Some(worker) = weak_worker.upgrade() { + let _ = worker.terminate().await; + } + break; + } + }; + if response.kind != "complete" || response.token != response_token { + rejected_frame_bytes = rejected_frame_bytes.saturating_add(frame.len()); + if rejected_frame_bytes <= 1024 * 1024 { + continue; + } + log::warn!("Script tool worker exceeded the unauthenticated stdout budget"); + if let Some(worker) = weak_worker.upgrade() { + let _ = worker.terminate().await; + } + break; + } + let Some(id) = response.id else { + continue; + }; + let Some(request) = pending.lock().await.remove(&id) else { + continue; + }; + let result = if response.ok { + Ok(response.result) + } else { + Err(PortError::new( + port_error_kind(response.error_kind.as_deref()), + response + .error + .unwrap_or_else(|| "script tool worker request failed".to_string()), + )) + }; + let _ = request.sender.send(result); + } + for (_, request) in pending.lock().await.drain() { + let _ = request.sender.send(Err(PortError::new( + PortErrorKind::NotAvailable, + "script tool worker exited", + ))); + } + let _ = exit_sender.send(true); + }); + tokio::spawn(async move { + let mut stderr = stderr; + if let Ok(bytes) = tokio::io::copy(&mut stderr, &mut tokio::io::sink()).await { + if bytes > 0 { + log::debug!("Script tool worker emitted {} stderr bytes", bytes); + } + } + }); + Ok(worker) + } + + async fn request(&self, request_type: &str, payload: Value) -> PortResult { + self.request_with_timeout(request_type, payload, REQUEST_TIMEOUT) + .await + } + + async fn request_with_timeout( + &self, + request_type: &str, + payload: Value, + timeout: std::time::Duration, + ) -> PortResult { + let id = self.next_request_id.fetch_add(1, Ordering::Relaxed); + let mut message = match payload { + Value::Object(map) => map, + _ => Map::new(), + }; + message.insert("id".to_string(), Value::from(id)); + message.insert("type".to_string(), Value::from(request_type)); + let encoded = serde_json::to_vec(&Value::Object(message)).map_err(|error| { + PortError::new( + PortErrorKind::InvalidRequest, + format!("failed to encode worker request: {error}"), + ) + })?; + if encoded.len() > MAX_PROTOCOL_FRAME_BYTES { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "script tool request exceeds the protocol limit", + )); + } + let (sender, receiver) = oneshot::channel(); + self.pending + .lock() + .await + .insert(id, PendingRequest { sender }); + let write_result = async { + let mut stdin = self.stdin.lock().await; + stdin.write_all(&encoded).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await + } + .await; + if let Err(error) = write_result { + self.pending.lock().await.remove(&id); + return Err(PortError::new( + PortErrorKind::NotAvailable, + format!("failed to write worker request: {error}"), + )); + } + match tokio::time::timeout(timeout, receiver).await { + Ok(response) => response.map_err(|_| { + PortError::new(PortErrorKind::NotAvailable, "worker response was dropped") + })?, + Err(_) => { + self.pending.lock().await.remove(&id); + let _ = self.terminate().await; + Err(PortError::new( + PortErrorKind::Timeout, + "script tool worker timed out and was terminated", + )) + } + } + } + + async fn terminate(&self) -> PortResult<()> { + let mut child = self.child.lock().await; + 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}"), + ) + }) + } + + async fn is_running(&self) -> bool { + matches!(self.child.lock().await.try_wait(), Ok(None)) + } + + async fn wait_for_exit(&self) { + let mut state = self.exit_state.clone(); + while !*state.borrow() { + if state.changed().await.is_err() { + break; + } + } + } + + async fn dispose(&self, target_id: &str) -> PortResult<()> { + let _ = self + .request_with_timeout( + "dispose", + serde_json::json!({ "targetId": target_id }), + CANCEL_GRACE_PERIOD, + ) + .await; + self.terminate().await + } +} + +struct InvocationDropGuard { + worker: Option>, + permit: Option, + target_id: String, + operation_id: String, +} + +impl InvocationDropGuard { + fn new( + worker: Arc, + permit: OwnedSemaphorePermit, + target_id: String, + operation_id: String, + ) -> Self { + Self { + worker: Some(worker), + permit: Some(permit), + target_id, + operation_id, + } + } + + fn disarm(mut self) { + self.worker.take(); + self.permit.take(); + } +} + +impl Drop for InvocationDropGuard { + fn drop(&mut self) { + let Some(worker) = self.worker.take() else { + return; + }; + let permit = self.permit.take(); + let target_id = std::mem::take(&mut self.target_id); + let operation_id = std::mem::take(&mut self.operation_id); + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + log::error!("Could not terminate a dropped script tool invocation outside Tokio"); + return; + }; + runtime.spawn(async move { + let cancelled = worker + .request_with_timeout( + "cancel", + serde_json::json!({ + "targetId": target_id, + "operationId": operation_id, + }), + CANCEL_GRACE_PERIOD, + ) + .await; + if cancelled.is_err() { + let _ = worker.terminate().await; + } + drop(permit); + }); + } +} + +fn port_error_kind(kind: Option<&str>) -> PortErrorKind { + match kind { + Some("not_found") => PortErrorKind::NotFound, + Some("invalid_request") => PortErrorKind::InvalidRequest, + Some("cancelled") => PortErrorKind::Cancelled, + Some("timeout") => PortErrorKind::Timeout, + _ => PortErrorKind::Backend, + } +} + +pub struct NodeScriptToolRuntime { + executable: Option, + workers: RwLock>>, + load_gate: Mutex<()>, +} + +impl Default for NodeScriptToolRuntime { + fn default() -> Self { + Self::discover() + } +} + +impl NodeScriptToolRuntime { + pub fn discover() -> Self { + Self { + executable: which::which("node").ok(), + workers: RwLock::new(HashMap::new()), + load_gate: Mutex::new(()), + } + } + + async fn evict_worker(&self, target_id: &str, worker: &Arc) { + let mut workers = self.workers.write().await; + if workers + .get(target_id) + .is_some_and(|current| Arc::ptr_eq(current, worker)) + { + workers.remove(target_id); + } + drop(workers); + let _ = worker.terminate().await; + } +} + +#[async_trait] +impl ScriptToolRuntime for NodeScriptToolRuntime { + async fn availability(&self) -> ScriptToolRuntimeAvailability { + match &self.executable { + Some(executable) => ScriptToolRuntimeAvailability::Available { + executable: executable.to_string_lossy().into_owned(), + version: "verified when first enabled".to_string(), + }, + None => ScriptToolRuntimeAvailability::Unavailable { + reason: "Node.js was not found; discovered tools remain disabled".to_string(), + }, + } + } + + async fn is_loaded(&self, target_id: &str) -> bool { + let Some(worker) = self.workers.read().await.get(target_id).cloned() else { + return false; + }; + if worker.is_running().await { + return true; + } + self.evict_worker(target_id, &worker).await; + false + } + + async fn wait_until_unloaded(&self, target_id: &str) -> PortResult<()> { + let worker = self + .workers + .read() + .await + .get(target_id) + .cloned() + .ok_or_else(|| { + PortError::new(PortErrorKind::NotFound, "script tool target is not loaded") + })?; + worker.wait_for_exit().await; + let mut workers = self.workers.write().await; + if !workers + .get(target_id) + .is_some_and(|current| Arc::ptr_eq(current, &worker)) + { + return Err(PortError::new( + PortErrorKind::NotFound, + "script tool target generation was replaced or disposed", + )); + } + workers.remove(target_id); + Ok(()) + } + + async fn load(&self, request: ScriptToolLoadRequest) -> PortResult { + let _guard = self.load_gate.lock().await; + let executable = self.executable.as_ref().ok_or_else(|| { + PortError::new(PortErrorKind::NotAvailable, "Node.js is not available") + })?; + if request.target_id.is_empty() + || request.revision.is_empty() + || request.expected_tools.is_empty() + { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "script tool load request is incomplete", + )); + } + if let Some(previous) = self.workers.write().await.remove(&request.target_id) { + previous.dispose(&request.target_id).await?; + } + let worker = NodeWorker::spawn(executable, &request.working_directory).await?; + let payload = serde_json::to_value(&request).map_err(|error| { + PortError::new( + PortErrorKind::InvalidRequest, + format!("failed to encode tool target: {error}"), + ) + })?; + let response = match worker.request("load", payload).await { + Ok(response) => response, + Err(error) => { + let _ = worker.dispose(&request.target_id).await; + return Err(error); + } + }; + let response = + serde_json::from_value::(response).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("invalid script tool load response: {error}"), + ) + })?; + self.workers.write().await.insert(request.target_id, worker); + Ok(response) + } + + async fn invoke( + &self, + request: ScriptToolInvokeRequest, + ) -> PortResult { + let worker = self + .workers + .read() + .await + .get(&request.target_id) + .cloned() + .ok_or_else(|| { + PortError::new(PortErrorKind::NotFound, "script tool target is not loaded") + })?; + let permit = Arc::clone(&worker.invoke_gate) + .try_acquire_owned() + .map_err(|_| { + PortError::new( + PortErrorKind::NotAvailable, + "script tool target is already running another invocation", + ) + })?; + let target_id = request.target_id.clone(); + let operation_id = request.operation_id.clone(); + let drop_guard = + InvocationDropGuard::new(Arc::clone(&worker), permit, target_id.clone(), operation_id); + let payload = serde_json::to_value(request).map_err(|error| { + PortError::new( + PortErrorKind::InvalidRequest, + format!("failed to encode tool invocation: {error}"), + ) + })?; + let response = worker.request("invoke", payload).await; + drop_guard.disarm(); + let response = match response { + Ok(response) => response, + Err(error) => { + if matches!( + error.kind, + PortErrorKind::Timeout | PortErrorKind::NotAvailable | PortErrorKind::NotFound + ) { + self.evict_worker(&target_id, &worker).await; + } + return Err(error); + } + }; + serde_json::from_value(response).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("invalid script tool invocation response: {error}"), + ) + }) + } + + async fn cancel(&self, target_id: &str, operation_id: &str) -> PortResult<()> { + let worker = self + .workers + .read() + .await + .get(target_id) + .cloned() + .ok_or_else(|| { + PortError::new(PortErrorKind::NotFound, "script tool target is not loaded") + })?; + let result = worker + .request_with_timeout( + "cancel", + serde_json::json!({ + "targetId": target_id, + "operationId": operation_id, + }), + CANCEL_GRACE_PERIOD, + ) + .await; + match result { + Ok(_) => Ok(()), + Err(error) + if matches!( + error.kind, + PortErrorKind::Timeout | PortErrorKind::NotAvailable + ) => + { + self.evict_worker(target_id, &worker).await; + Ok(()) + } + Err(error) => Err(error), + } + } + + async fn dispose(&self, target_id: &str) -> PortResult<()> { + let Some(worker) = self.workers.write().await.remove(target_id) else { + return Ok(()); + }; + worker.dispose(target_id).await + } +} diff --git a/src/crates/services/services-integrations/tests/script_tool_runtime.rs b/src/crates/services/services-integrations/tests/script_tool_runtime.rs new file mode 100644 index 0000000000..4a373c055d --- /dev/null +++ b/src/crates/services/services-integrations/tests/script_tool_runtime.rs @@ -0,0 +1,884 @@ +use bitfun_runtime_ports::{ + PortErrorKind, ScriptToolExpectedExport, ScriptToolInvokeRequest, ScriptToolLoadRequest, + ScriptToolRuntime, ScriptToolRuntimeAvailability, +}; +use bitfun_services_integrations::script_tool::NodeScriptToolRuntime; +use serde_json::json; + +fn sample_source(output: &str) -> String { + format!( + r#" +const schema = {{ + string: () => ({{ type: "string" }}), +}}; +const tool = (definition) => definition; +tool.schema = schema; +export default tool({{ + description: "Greets a person", + args: {{ name: tool.schema.string() }}, + async execute(args) {{ return `${{args.name}}: {output}`; }}, +}}); +"# + ) +} + +fn load_request(revision: &str, source: String) -> ScriptToolLoadRequest { + ScriptToolLoadRequest { + target_id: "target-1".to_string(), + revision: revision.to_string(), + module_source: source, + module_url: "file:///workspace/.opencode/tools/greet.js".to_string(), + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + expected_tools: vec![ScriptToolExpectedExport { + export_name: "default".to_string(), + tool_name: "greet".to_string(), + }], + } +} + +fn invoke_request(operation_id: &str, revision: &str) -> ScriptToolInvokeRequest { + ScriptToolInvokeRequest { + target_id: "target-1".to_string(), + revision: revision.to_string(), + export_name: "default".to_string(), + operation_id: operation_id.to_string(), + arguments: json!({}), + workspace_root: None, + worktree_root: None, + session_id: None, + } +} + +fn named_invoke_request(operation_id: &str, revision: &str) -> ScriptToolInvokeRequest { + let mut request = invoke_request(operation_id, revision); + request.arguments = json!({"name": "Ada"}); + request +} + +#[tokio::test] +async fn node_worker_loads_invokes_updates_and_disposes_a_target() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + + let loaded = runtime + .load(load_request("v1", sample_source("hello"))) + .await + .unwrap(); + assert_eq!(loaded.tools.len(), 1); + assert_eq!(loaded.tools[0].name, "greet"); + assert_eq!(loaded.tools[0].input_schema["required"], json!(["name"])); + assert_eq!( + runtime + .invoke(named_invoke_request("operation-1", "v1")) + .await + .unwrap() + .output, + "Ada: hello" + ); + + runtime + .load(load_request("v2", sample_source("updated"))) + .await + .unwrap(); + assert_eq!( + runtime + .invoke(named_invoke_request("operation-2", "v2")) + .await + .unwrap() + .output, + "Ada: updated" + ); + + runtime.dispose("target-1").await.unwrap(); + let error = runtime + .invoke(invoke_request("operation-3", "v2")) + .await + .unwrap_err(); + assert_eq!(error.kind, PortErrorKind::NotFound); +} + +#[tokio::test] +async fn failed_update_withdraws_the_previous_revision() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + + runtime + .load(load_request("v1", sample_source("hello"))) + .await + .unwrap(); + assert!(runtime + .load(load_request("v2", "export default {".to_string())) + .await + .is_err()); + + let error = runtime + .invoke(invoke_request("operation-2", "v1")) + .await + .unwrap_err(); + assert_eq!(error.kind, PortErrorKind::NotFound); +} + +#[tokio::test] +async fn cancellation_reaches_the_tool_abort_signal() { + let runtime = std::sync::Arc::new(NodeScriptToolRuntime::discover()); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Waits until cancelled", + args: {}, + execute(_args, context) { + return new Promise((_resolve, reject) => { + context.abort.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); + }); + }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + let invoking = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .invoke(invoke_request("operation-cancel", "v1")) + .await + }) + }; + tokio::task::yield_now().await; + runtime + .cancel("target-1", "operation-cancel") + .await + .unwrap(); + + let error = tokio::time::timeout(std::time::Duration::from_secs(2), invoking) + .await + .expect("invoke should finish after cancellation") + .unwrap() + .unwrap_err(); + assert_eq!(error.kind, PortErrorKind::Cancelled); + assert!(runtime.is_loaded("target-1").await); +} + +#[tokio::test] +async fn pinned_cancellation_drains_the_invoke_and_keeps_a_cooperative_worker() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Waits until cancelled", + args: {}, + execute(_args, context) { + return new Promise((_resolve, reject) => { + context.abort.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); + }); + }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + let mut invoking = Box::pin(runtime.invoke(invoke_request("pinned-cancel", "v1"))); + tokio::select! { + result = &mut invoking => panic!("invoke finished before cancellation: {result:?}"), + _ = tokio::time::sleep(std::time::Duration::from_millis(25)) => {} + } + + runtime.cancel("target-1", "pinned-cancel").await.unwrap(); + let error = invoking.await.unwrap_err(); + + assert_eq!(error.kind, PortErrorKind::Cancelled); + assert!(runtime.is_loaded("target-1").await); +} + +#[tokio::test] +async fn cancellation_hard_stops_an_async_tool_that_ignores_abort() { + let runtime = std::sync::Arc::new(NodeScriptToolRuntime::discover()); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Ignores cancellation", + args: {}, + execute() { return new Promise(() => {}); }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + let invoking = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .invoke(invoke_request("operation-ignore-abort", "v1")) + .await + }) + }; + tokio::task::yield_now().await; + + tokio::time::timeout( + std::time::Duration::from_secs(2), + runtime.cancel("target-1", "operation-ignore-abort"), + ) + .await + .expect("cancel should hard-stop an operation that ignores AbortSignal") + .unwrap(); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), invoking) + .await + .expect("invoke should finish after hard cancellation") + .unwrap() + .is_err() + ); + assert!(!runtime.is_loaded("target-1").await); +} + +#[tokio::test] +async fn tool_stdout_cannot_forge_worker_protocol_responses() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +const originalParse = JSON.parse.bind(JSON); +let intercepted; +JSON.parse = (text) => { + const message = originalParse(text); + intercepted = message; + return message; +}; +export default { + description: "Writes to stdout", + args: {}, + execute() { + const id = intercepted?.id ?? 2; + const nonce = intercepted?.nonce ?? "guessed"; + process.getBuiltinModule("fs").writeSync(1, JSON.stringify({ id, nonce, ok: true, result: { output: "forged" } }) + "\n"); + return "real"; + }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + let response = runtime + .invoke(invoke_request("operation-stdout", "v1")) + .await + .unwrap(); + assert_eq!(response.output, "real"); +} + +#[tokio::test] +async fn guessed_completion_frame_is_ignored_in_favor_of_the_real_result() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Attempts an early completion", + args: {}, + execute() { + process.getBuiltinModule("fs").writeSync(1, JSON.stringify({ + kind: "complete", + token: "guessed", + ok: true, + result: { output: "forged" }, + }) + "\n"); + return "real"; + }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + assert_eq!( + runtime + .invoke(invoke_request("operation-forged-completion", "v1")) + .await + .unwrap() + .output, + "real" + ); +} + +#[tokio::test] +async fn escaped_control_character_output_stays_within_the_protocol_budget() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Returns escaped output", + args: {}, + execute() { return "\0".repeat(400_000); }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + let response = runtime + .invoke(invoke_request("operation-controls", "v1")) + .await + .unwrap(); + assert_eq!(response.output.len(), 400_000); +} + +#[tokio::test] +async fn schema_validation_does_not_accept_properties_from_the_prototype_chain() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Requires a prototype-named property", + args: { toString: { type: "string" } }, + execute(args) { return args.toString; }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + let mut request = invoke_request("operation-prototype", "v1"); + request.arguments = json!({}); + let error = runtime.invoke(request).await.unwrap_err(); + assert_eq!(error.kind, PortErrorKind::InvalidRequest); +} + +#[tokio::test] +async fn oversized_untrusted_stdout_fails_without_unbounded_protocol_buffering() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Floods invalid protocol output", + args: {}, + execute() { + process.getBuiltinModule("fs").writeSync(1, "invalid\n".repeat(1_100_000)); + return "unreachable"; + }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + assert!(runtime + .invoke(invoke_request("operation-invalid-flood", "v1")) + .await + .is_err()); + assert!(!runtime.is_loaded("target-1").await); +} + +#[tokio::test] +async fn target_rejects_concurrent_invocations_instead_of_growing_an_unbounded_queue() { + let runtime = std::sync::Arc::new(NodeScriptToolRuntime::discover()); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Waits", + args: {}, + execute(_args, context) { + return new Promise((_resolve, reject) => { + context.abort.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); + }); + }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + let first = { + let runtime = runtime.clone(); + tokio::spawn(async move { runtime.invoke(invoke_request("first", "v1")).await }) + }; + tokio::task::yield_now().await; + + let error = runtime + .invoke(invoke_request("second", "v1")) + .await + .unwrap_err(); + assert_eq!(error.kind, PortErrorKind::NotAvailable); + runtime.cancel("target-1", "first").await.unwrap(); + assert_eq!( + first.await.unwrap().unwrap_err().kind, + PortErrorKind::Cancelled + ); +} + +#[tokio::test] +async fn worker_enforces_the_schema_it_exposes_to_the_model() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + runtime + .load(load_request("v1", sample_source("validated"))) + .await + .unwrap(); + + for arguments in [ + json!({}), + json!({"name": 42}), + json!({"name": "Ada", "extra": true}), + ] { + let mut request = invoke_request("invalid-schema", "v1"); + request.arguments = arguments; + let error = runtime.invoke(request).await.unwrap_err(); + assert_eq!(error.kind, PortErrorKind::InvalidRequest); + } +} + +#[tokio::test] +async fn cancellation_terminates_a_target_that_blocks_the_javascript_event_loop() { + let runtime = std::sync::Arc::new(NodeScriptToolRuntime::discover()); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Blocks forever", + args: {}, + execute() { while (true) {} }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + let invoking = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .invoke(invoke_request("operation-blocked", "v1")) + .await + }) + }; + tokio::task::yield_now().await; + tokio::time::timeout( + std::time::Duration::from_secs(2), + runtime.cancel("target-1", "operation-blocked"), + ) + .await + .expect("hard cancellation should terminate a blocked worker") + .unwrap(); + + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), invoking) + .await + .expect("invoke should finish after the worker is terminated") + .unwrap() + .is_err() + ); + assert!(!runtime.is_loaded("target-1").await); +} + +#[tokio::test] +async fn disposal_terminates_a_target_that_blocks_the_javascript_event_loop() { + let runtime = std::sync::Arc::new(NodeScriptToolRuntime::discover()); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Blocks forever", + args: {}, + execute() { while (true) {} }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + let invoking = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .invoke(invoke_request("operation-dispose", "v1")) + .await + }) + }; + tokio::task::yield_now().await; + + tokio::time::timeout( + std::time::Duration::from_secs(2), + runtime.dispose("target-1"), + ) + .await + .expect("dispose should hard-stop a blocked worker") + .unwrap(); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), invoking) + .await + .expect("invoke should finish after the worker is disposed") + .unwrap() + .is_err() + ); +} + +#[tokio::test] +async fn process_exit_fails_the_call_without_forging_a_successful_result() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Stops its worker", + args: {}, + execute() { process.exit(23); }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + assert!(runtime + .invoke(invoke_request("operation-exit", "v1")) + .await + .is_err()); + assert!(!runtime.is_loaded("target-1").await); +} + +#[tokio::test] +async fn invocation_receives_the_real_workspace_context() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Reports context", + args: {}, + execute(_args, context) { + return JSON.stringify({ + directory: context.directory, + worktree: context.worktree, + sessionID: context.sessionID, + }); + }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + let mut request = invoke_request("operation-context", "v1"); + request.workspace_root = Some("opened-workspace".to_string()); + request.worktree_root = Some("git-worktree".to_string()); + request.session_id = Some("session-42".to_string()); + let output = runtime.invoke(request).await.unwrap().output; + assert_eq!( + serde_json::from_str::(&output).unwrap(), + json!({ + "directory": "opened-workspace", + "worktree": "git-worktree", + "sessionID": "session-42", + }) + ); +} + +#[tokio::test] +async fn target_keeps_one_module_instance_across_invocations() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +let invocationCount = 0; +export default { + description: "Counts calls", + args: {}, + execute() { invocationCount += 1; return String(invocationCount); }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + assert_eq!( + runtime + .invoke(invoke_request("operation-state-1", "v1")) + .await + .unwrap() + .output, + "1" + ); + assert_eq!( + runtime + .invoke(invoke_request("operation-state-2", "v1")) + .await + .unwrap() + .output, + "2" + ); +} + +#[tokio::test] +async fn ordinary_console_logging_does_not_corrupt_the_worker_protocol() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +console.log("loaded"); +export default { + description: "Logs normally", + args: {}, + execute() { console.log("invoked"); return "ok"; }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + assert_eq!( + runtime + .invoke(invoke_request("operation-console", "v1")) + .await + .unwrap() + .output, + "ok" + ); +} + +#[tokio::test] +async fn worker_sets_import_meta_url_to_the_prepared_module_url() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Reports its module URL", + args: {}, + execute() { return import.meta.url; }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + assert_eq!( + runtime + .invoke(invoke_request("operation-import-meta", "v1")) + .await + .unwrap() + .output, + "file:///workspace/.opencode/tools/greet.js" + ); +} + +#[tokio::test] +async fn idle_worker_exit_is_reported_and_evicted_without_an_invocation() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +setTimeout(() => process.exit(0), 50); +export default { + description: "Exits while idle", + args: {}, + execute() { return "unreachable"; }, +}; +"#; + runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + + tokio::time::timeout( + std::time::Duration::from_secs(2), + runtime.wait_until_unloaded("target-1"), + ) + .await + .expect("idle exit notification") + .expect("current worker exit"); + + assert!(!runtime.is_loaded("target-1").await); + assert_eq!( + runtime + .invoke(invoke_request("after-idle-exit", "v1")) + .await + .unwrap_err() + .kind, + PortErrorKind::NotFound + ); +} + +#[tokio::test] +async fn dropping_an_invocation_future_terminates_before_late_side_effects() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let temp = tempfile::tempdir().unwrap(); + let marker = temp.path().join("late-side-effect.txt"); + let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap(); + let source = format!( + r#" +const fs = process.getBuiltinModule("node:fs"); +export default {{ + description: "Attempts a late side effect", + args: {{}}, + async execute() {{ + await new Promise((resolve) => setTimeout(resolve, 800)); + fs.writeFileSync({marker_literal}, "late"); + return "late"; + }}, +}}; +"# + ); + runtime.load(load_request("v1", source)).await.unwrap(); + + assert!(tokio::time::timeout( + std::time::Duration::from_millis(50), + runtime.invoke(invoke_request("outer-timeout", "v1")), + ) + .await + .is_err()); + tokio::time::timeout( + std::time::Duration::from_secs(2), + runtime.wait_until_unloaded("target-1"), + ) + .await + .expect("dropped invocation terminates worker") + .expect("current worker exit"); + tokio::time::sleep(std::time::Duration::from_millis(850)).await; + + assert!(!marker.exists()); +} + +#[tokio::test] +async fn worker_materializes_defaults_and_enforces_array_bounds() { + let runtime = NodeScriptToolRuntime::discover(); + if matches!( + runtime.availability().await, + ScriptToolRuntimeAvailability::Unavailable { .. } + ) { + return; + } + let source = r#" +export default { + description: "Uses defaults", + args: { + greeting: { type: "string", default: "hello" }, + tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 3 }, + }, + execute(args) { return `${args.greeting}:${args.tags.join(",")}`; }, +}; +"#; + let loaded = runtime + .load(load_request("v1", source.to_string())) + .await + .unwrap(); + assert_eq!(loaded.tools[0].input_schema["required"], json!(["tags"])); + + let mut valid = invoke_request("operation-default", "v1"); + valid.arguments = json!({"tags": ["a", "b"]}); + assert_eq!(runtime.invoke(valid).await.unwrap().output, "hello:a,b"); + + let mut invalid = invoke_request("operation-array-min", "v1"); + invalid.arguments = json!({"tags": ["only-one"]}); + assert_eq!( + runtime.invoke(invalid).await.unwrap_err().kind, + PortErrorKind::InvalidRequest + ); +} 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 c869dbc942..c13165bce4 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -70,9 +70,82 @@ export interface ExternalSourceCatalogSnapshot { availability: PromptCommandAvailability; }>; }>; + tools?: ExternalToolCatalogEntry[]; + toolApprovalRequests?: ExternalToolApprovalRequest[]; + toolConflicts?: ExternalToolConflict[]; diagnostics?: Array<{ severity: string; code: string; message: string }>; } +export type ExternalToolCapability = 'file_system' | 'network' | 'process' | 'environment'; +export type ExternalToolActivation = + | { state: 'approval_required' } + | { state: 'disabled' } + | { state: 'active' } + | { state: 'conflict' } + | { state: 'unsupported'; reason: string } + | { state: 'runtime_unavailable'; reason: string } + | { state: 'load_failed'; reason: string }; + +export interface ExternalToolDefinition { + id: { + target: { + source: { providerId: string; sourceId: string }; + localId: string; + }; + exportId: string; + }; + name: string; + descriptionPreview: string; + modulePath: string; + workingDirectory: string; + runtimeKind: 'java_script' | 'type_script'; + capabilities: ExternalToolCapability[]; + contentVersion: string; + staticStatus: + | { state: 'ready' } + | { state: 'unsupported'; reason: string } + | { state: 'invalid'; reason: string }; +} + +export interface ExternalToolCatalogEntry { + definition: ExternalToolDefinition; + approvalKey: string; + decisionKey: string; + activation: ExternalToolActivation; +} + +export interface ExternalToolApprovalRequest { + approvalKey: string; + decisionKey: string; + targetId: { + source: { providerId: string; sourceId: string }; + localId: string; + }; + sourceDisplayName: string; + sourceScope: ExternalSourceScope; + sourceLocation: string; + workingDirectory: string; + runtimeKind: 'java_script' | 'type_script'; + capabilities: ExternalToolCapability[]; + contentVersion: string; + toolNames: string[]; +} + +export interface ExternalToolConflict { + conflictKey: string; + toolName: string; + selectedCandidateId?: string; + candidates: Array<{ + candidateId: string; + displayName: string; + kind: 'built_in' | 'mcp' | 'external'; + providerId: string; + contentVersion: string; + source?: { providerId: string; sourceId: string }; + sourceLocation?: string; + }>; +} + export const externalSourcesAPI = { getSnapshot(workspacePath?: string, forceRefresh = false) { return api.invoke('get_external_source_snapshot', { @@ -91,4 +164,25 @@ export const externalSourcesAPI = { request: { workspacePath, conflictKey, candidateId }, }); }, + + setToolTargetDecision( + workspacePath: string | undefined, + approvalKey: string, + decisionKey: string, + approved: boolean, + ) { + return api.invoke('set_external_tool_target_decision_command', { + request: { workspacePath, approvalKey, decisionKey, approved }, + }); + }, + + setToolConflictChoice( + workspacePath: string | undefined, + conflictKey: string, + candidateId: string, + ) { + return api.invoke('set_external_tool_conflict_choice_command', { + request: { workspacePath, conflictKey, candidateId }, + }); + }, }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index fed433dca4..6c9df75d34 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -53,6 +53,38 @@ } } + &__tool-card { + padding: 12px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--color-bg-secondary); + overflow-wrap: anywhere; + + & + & { + margin-top: 10px; + } + } + + &__tool-detail { + display: grid; + gap: 4px; + color: var(--color-text-secondary); + font-size: 11px; + } + + &__tool-warning { + margin-top: 8px; + color: var(--color-warning); + font-size: 11px; + } + + &__tool-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; + } + &__conflict-title { margin-bottom: 8px; color: var(--color-text-primary); 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 cbfec899da..3bf572c132 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -8,6 +8,8 @@ import ExternalSourcesConfig from './ExternalSourcesConfig'; const getSnapshotMock = vi.hoisted(() => vi.fn()); const setSourceEnabledMock = vi.hoisted(() => vi.fn()); const setConflictChoiceMock = vi.hoisted(() => vi.fn()); +const setToolTargetDecisionMock = vi.hoisted(() => vi.fn()); +const setToolConflictChoiceMock = vi.hoisted(() => vi.fn()); const workspaceState = vi.hoisted(() => ({ path: 'D:/workspace/project' })); vi.mock('react-i18next', () => ({ @@ -35,6 +37,8 @@ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ getSnapshot: getSnapshotMock, setSourceEnabled: setSourceEnabledMock, setConflictChoice: setConflictChoiceMock, + setToolTargetDecision: setToolTargetDecisionMock, + setToolConflictChoice: setToolConflictChoiceMock, }, })); @@ -86,6 +90,9 @@ const snapshot = { availability: { state: 'available' }, }], }], + tools: [], + toolApprovalRequests: [], + toolConflicts: [], }; describe('ExternalSourcesConfig', () => { @@ -104,6 +111,8 @@ describe('ExternalSourcesConfig', () => { selectedCandidateId: 'candidate-opencode', }], }); + setToolTargetDecisionMock.mockResolvedValue(snapshot); + setToolConflictChoiceMock.mockResolvedValue(snapshot); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -145,7 +154,7 @@ describe('ExternalSourcesConfig', () => { ); }); - it('keeps a neutral checking state until initial discovery completes', async () => { + it('keeps discovery non-blocking while an initial refresh completes', async () => { getSnapshotMock .mockResolvedValueOnce({ ...snapshot, @@ -160,14 +169,238 @@ describe('ExternalSourcesConfig', () => { root.render(); await Promise.resolve(); }); - expect(container.textContent).toContain('loading'); + expect(container.textContent).toContain('checkingNonBlocking'); expect(container.textContent).not.toContain('sources.empty'); await act(async () => { await vi.advanceTimersByTimeAsync(750); }); expect(container.textContent).toContain('OpenCode project commands'); - expect(container.textContent).not.toContain('loading'); + expect(container.textContent).not.toContain('checkingNonBlocking'); + }); + + it('shows source, working directory, and capabilities before enabling tool code', async () => { + const approvalSnapshot = { + ...snapshot, + sources: [{ + stableKey: 'tool-source', + record: { + ...snapshot.sources[0].record, + key: { providerId: 'opencode.tools', sourceId: 'project' }, + displayName: 'OpenCode project tools', + sourceKind: 'tools', + location: 'D:/workspace/project/.opencode/tools', + executionDomainId: 'local:D:/workspace/project', + }, + lifecycle: 'available', + }], + commandConflicts: [], + tools: [{ + definition: { + id: { + target: { + source: { providerId: 'opencode.tools', sourceId: 'project' }, + localId: 'weather.js', + }, + exportId: 'default', + }, + name: 'weather', + descriptionPreview: 'Read the weather', + modulePath: 'D:/workspace/project/.opencode/tools/weather.js', + workingDirectory: 'D:/workspace/project', + runtimeKind: 'java_script', + capabilities: ['file_system', 'network', 'environment', 'process'], + contentVersion: 'v1', + staticStatus: { state: 'ready' }, + }, + approvalKey: 'approval-1', + decisionKey: 'decision-1', + activation: { state: 'approval_required' }, + }], + toolApprovalRequests: [{ + approvalKey: 'approval-1', + decisionKey: 'decision-1', + targetId: { + source: { providerId: 'opencode.tools', sourceId: 'project' }, + localId: 'weather.js', + }, + sourceDisplayName: 'OpenCode project tools', + sourceScope: 'project', + sourceLocation: 'D:/workspace/project/.opencode/tools/weather.js', + workingDirectory: 'D:/workspace/project', + runtimeKind: 'java_script', + capabilities: ['file_system', 'network', 'environment', 'process'], + contentVersion: 'v1', + toolNames: ['weather'], + }], + }; + getSnapshotMock.mockResolvedValue(approvalSnapshot); + setToolTargetDecisionMock.mockResolvedValue({ + ...approvalSnapshot, + toolApprovalRequests: [], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('toolApprovals.sourceRoot'); + expect(container.textContent).toContain('toolApprovals.modulePath'); + expect(container.textContent).toContain('D:/workspace/project/.opencode/tools/weather.js'); + expect(container.textContent).toContain('local:D:/workspace/project'); + expect(container.textContent).toContain('toolApprovals.workingDirectory'); + expect(container.textContent).toContain('capability.file_system'); + expect(container.textContent).toContain('capability.environment'); + const enable = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('toolApprovals.enable')); + await act(async () => enable?.click()); + + expect(setToolTargetDecisionMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'approval-1', + 'decision-1', + true, + ); + const operationStatus = container.querySelector('[role="status"][tabindex="-1"]'); + expect(operationStatus?.textContent).toContain('actions.updated'); + expect(document.activeElement).toBe(operationStatus); + }); + + it('lets a previously declined tool be reviewed and enabled without another automatic prompt', async () => { + const disabledSnapshot = { + ...snapshot, + commandConflicts: [], + tools: [{ + definition: { + id: { + target: { + source: { providerId: 'opencode.tools', sourceId: 'project' }, + localId: 'weather.js', + }, + exportId: 'default', + }, + name: 'weather', + descriptionPreview: 'Read the weather', + modulePath: 'D:/workspace/project/.opencode/tools/weather.js', + workingDirectory: 'D:/workspace/project', + runtimeKind: 'java_script', + capabilities: ['file_system', 'network', 'environment', 'process'], + contentVersion: 'v1', + staticStatus: { state: 'ready' }, + }, + approvalKey: 'approval-1', + decisionKey: 'decision-1', + activation: { state: 'disabled' }, + }], + toolApprovalRequests: [], + }; + getSnapshotMock.mockResolvedValue(disabledSnapshot); + setToolTargetDecisionMock.mockResolvedValue({ + ...disabledSnapshot, + tools: [{ ...disabledSnapshot.tools[0], activation: { state: 'active' } }], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).not.toContain('toolApprovals.warning'); + const review = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('tools.details')); + await act(async () => review?.click()); + expect(container.textContent).toContain('toolApprovals.warning'); + expect(container.textContent).toContain('capability.network'); + + const enable = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('toolApprovals.enable')); + await act(async () => enable?.click()); + expect(setToolTargetDecisionMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'approval-1', + 'decision-1', + true, + ); + }); + + it('shows source, execution scope, failure reason, and next step for every tool state', async () => { + const toolSource = { + stableKey: 'tool-source', + record: { + ...snapshot.sources[0].record, + key: { providerId: 'opencode.tools', sourceId: 'project' }, + displayName: 'OpenCode project tools', + sourceKind: 'tools', + location: 'D:/workspace/project/.opencode/tools', + executionDomainId: 'local:D:/workspace/project', + }, + lifecycle: 'available', + }; + const toolDefinition = { + id: { + target: { + source: { providerId: 'opencode.tools', sourceId: 'project' }, + localId: 'weather.ts', + }, + exportId: 'default', + }, + name: 'weather', + descriptionPreview: 'Read the weather', + modulePath: 'D:/workspace/project/.opencode/tools/weather.ts', + workingDirectory: 'D:/workspace/project', + runtimeKind: 'type_script', + capabilities: ['file_system', 'network'], + contentVersion: 'v1', + staticStatus: { state: 'ready' }, + }; + const stateSnapshot = { + ...snapshot, + sources: [toolSource], + commandConflicts: [], + tools: [ + { + definition: toolDefinition, + approvalKey: 'approval-disabled', + decisionKey: 'decision-disabled', + activation: { state: 'disabled' }, + }, + { + definition: { + ...toolDefinition, + id: { + ...toolDefinition.id, + target: { ...toolDefinition.id.target, localId: 'broken.ts' }, + }, + name: 'broken', + modulePath: 'D:/workspace/project/.opencode/tools/broken.ts', + }, + approvalKey: 'approval-broken', + decisionKey: 'decision-broken', + activation: { state: 'load_failed', reason: 'Worker could not import the module.' }, + }, + ], + toolApprovalRequests: [], + }; + getSnapshotMock.mockResolvedValue(stateSnapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const detailButtons = Array.from(container.querySelectorAll('button')).filter((button) => + button.textContent?.includes('tools.details')); + expect(detailButtons).toHaveLength(2); + await act(async () => detailButtons[1]?.click()); + + expect(container.textContent).toContain('D:/workspace/project/.opencode/tools/broken.ts'); + expect(container.textContent).toContain('D:/workspace/project/.opencode/tools'); + expect(container.textContent).toContain('local:D:/workspace/project'); + expect(container.textContent).not.toContain('Worker could not import the module.'); + expect(container.textContent).toContain('toolReason.load_failed'); + expect(container.textContent).toContain('toolNextStep.load_failed'); + expect(container.textContent).toContain('tools.targetScope'); }); it('renders a removed source as disabled and off', async () => { diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 50ebbe4b9e..5d5b0d05ed 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -8,6 +8,7 @@ import { isRemoteWorkspace } from '@/shared/types'; import { externalSourcesAPI, type ExternalSourceCatalogSnapshot, + type ExternalToolCatalogEntry, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { ConfigPageContent, @@ -24,6 +25,14 @@ function abbreviatedLocation(location: string): string { return segments.length <= 3 ? normalized : `…/${segments.slice(-3).join('/')}`; } +function matchesToolSource( + source: ExternalSourceCatalogSnapshot['sources'][number], + tool: ExternalToolCatalogEntry, +): boolean { + return source.record.key.providerId === tool.definition.id.target.source.providerId + && source.record.key.sourceId === tool.definition.id.target.source.sourceId; +} + const ExternalSourcesConfig: React.FC = () => { const { t } = useTranslation('settings/external-sources'); const { workspace, workspacePath } = useCurrentWorkspace(); @@ -33,7 +42,11 @@ const ExternalSourcesConfig: React.FC = () => { const [loading, setLoading] = useState(desktopRuntime && !remoteWorkspace); const [refreshing, setRefreshing] = useState(false); const [busyKey, setBusyKey] = useState(null); + const [reviewingToolKey, setReviewingToolKey] = useState(null); const [error, setError] = useState(null); + const [operationStatus, setOperationStatus] = useState(null); + const operationStatusRef = useRef(null); + const focusOperationStatus = useRef(false); const requestSequence = useRef(0); const acceptedSequence = useRef(0); const pendingMutations = useRef(new Map()); @@ -107,7 +120,10 @@ const ExternalSourcesConfig: React.FC = () => { useEffect(() => { setSnapshot(null); setError(null); + setOperationStatus(null); + focusOperationStatus.current = false; setBusyKey(null); + setReviewingToolKey(null); setLoading(desktopRuntime && !remoteWorkspace); void loadSnapshot(false, false); if (!desktopRuntime || remoteWorkspace) return undefined; @@ -115,6 +131,13 @@ const ExternalSourcesConfig: React.FC = () => { return () => window.clearInterval(timer); }, [desktopRuntime, loadSnapshot, remoteWorkspace, workspacePath]); + useEffect(() => { + if (operationStatus && focusOperationStatus.current) { + focusOperationStatus.current = false; + operationStatusRef.current?.focus(); + } + }, [operationStatus]); + useEffect(() => { if (!desktopRuntime || remoteWorkspace || !snapshot?.discoveryPending) return undefined; const timer = window.setInterval(() => void loadSnapshot(false, false), 750); @@ -143,6 +166,16 @@ const ExternalSourcesConfig: React.FC = () => { ); }, [snapshot]); + const toolCounts = useMemo(() => { + const counts = new Map(); + for (const tool of snapshot?.tools ?? []) { + const source = tool.definition.id.target.source; + const key = `${source.providerId}\u0000${source.sourceId}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; + }, [snapshot?.tools]); + const pendingConflicts = useMemo( () => (snapshot?.commandConflicts ?? []).filter( (conflict) => !conflict.selectedCandidateId, @@ -150,59 +183,89 @@ const ExternalSourcesConfig: React.FC = () => { [snapshot?.commandConflicts], ); - const setEnabled = useCallback(async (sourceKey: string, enabled: boolean) => { + const pendingToolConflicts = useMemo( + () => (snapshot?.toolConflicts ?? []).filter( + (conflict) => !conflict.selectedCandidateId, + ), + [snapshot?.toolConflicts], + ); + + const runMutation = useCallback(async ( + mutationKey: string, + request: () => Promise, + focusResult = false, + ): Promise => { const scope = requestScope; const sequence = ++requestSequence.current; pendingMutations.current.set(sequence, scope); latestMutationByScope.current.set(scope, sequence); - setBusyKey(sourceKey); + setBusyKey(mutationKey); + setOperationStatus(null); try { setError(null); - const next = await externalSourcesAPI.setSourceEnabled(workspacePath, sourceKey, enabled); - acceptMutationSnapshot(next, scope, sequence); + const next = await request(); + const accepted = acceptMutationSnapshot(next, scope, sequence); + if (accepted) { + focusOperationStatus.current = focusResult; + setOperationStatus(t('actions.updated')); + } + return accepted; } catch (updateError) { if (requestScopeRef.current === scope && latestMutationByScope.current.get(scope) === sequence) { acceptedSequence.current = sequence; setError(updateError instanceof Error ? updateError.message : String(updateError)); } + return false; } finally { pendingMutations.current.delete(sequence); if (requestScopeRef.current === scope) { - setBusyKey((current) => (current === sourceKey ? null : current)); + setBusyKey((current) => (current === mutationKey ? null : current)); } } - }, [acceptMutationSnapshot, requestScope, workspacePath]); + }, [acceptMutationSnapshot, requestScope, t]); + + const setEnabled = useCallback(async (sourceKey: string, enabled: boolean) => { + await runMutation( + sourceKey, + () => externalSourcesAPI.setSourceEnabled(workspacePath, sourceKey, enabled), + ); + }, [runMutation, workspacePath]); const chooseConflict = useCallback(async (conflictKey: string, candidateId: string) => { - const scope = requestScope; - const sequence = ++requestSequence.current; - pendingMutations.current.set(sequence, scope); - latestMutationByScope.current.set(scope, sequence); - setBusyKey(conflictKey); - try { - setError(null); - const next = await externalSourcesAPI.setConflictChoice( + await runMutation( + conflictKey, + () => externalSourcesAPI.setConflictChoice(workspacePath, conflictKey, candidateId), + true, + ); + }, [runMutation, workspacePath]); + + const decideToolTarget = useCallback(async ( + approvalKey: string, + decisionKey: string, + approved: boolean, + ) => { + return runMutation( + decisionKey, + () => externalSourcesAPI.setToolTargetDecision( workspacePath, - conflictKey, - candidateId, - ); - acceptMutationSnapshot(next, scope, sequence); - } catch (updateError) { - if (requestScopeRef.current === scope - && latestMutationByScope.current.get(scope) === sequence) { - acceptedSequence.current = sequence; - setError(updateError instanceof Error ? updateError.message : String(updateError)); - } - } finally { - pendingMutations.current.delete(sequence); - if (requestScopeRef.current === scope) { - setBusyKey((current) => (current === conflictKey ? null : current)); - } - } - }, [acceptMutationSnapshot, requestScope, workspacePath]); + approvalKey, + decisionKey, + approved, + ), + true, + ); + }, [runMutation, workspacePath]); - if (loading || snapshot?.discoveryPending) { + const chooseToolConflict = useCallback(async (conflictKey: string, candidateId: string) => { + await runMutation( + conflictKey, + () => externalSourcesAPI.setToolConflictChoice(workspacePath, conflictKey, candidateId), + true, + ); + }, [runMutation, workspacePath]); + + if (loading && !snapshot) { return ; } @@ -224,7 +287,7 @@ const ExternalSourcesConfig: React.FC = () => { disabled={refreshing} onClick={() => void loadSnapshot(true, true)} > - +