diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fb4a4c376..92564e01e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,7 +213,9 @@ jobs: # exist during check/test; distributable assets remain frontend-build's owner. - name: Create Tauri resource directories shell: bash - run: mkdir -p dist src/mobile-web/dist + run: | + mkdir -p dist src/mobile-web/dist src/apps/extension-host/dist + touch src/apps/extension-host/dist/extension-host.js - name: Install Linux system dependencies (Tauri) if: runner.os == 'Linux' diff --git a/AGENTS-CN.md b/AGENTS-CN.md index b61220b9b4..6046c9506e 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -202,9 +202,9 @@ BitFun 不是只在本地运行的桌面应用:工作区、执行这一轮的 ### Agent Hooks -- BitFun 实现的是 Codex Hook 契约,因此 是事件、载荷字段与决策结构的参考来源,不要另起炉灶。[`docs/features/agent-hooks.zh-CN.md`](docs/features/agent-hooks.zh-CN.md)([English](docs/features/agent-hooks.md))只覆盖 BitFun 特有部分 —— 文件位置、`app.hooks` 开关和差异表 —— 新增或消除差异时必须同步更新。 +- BitFun 的原生用户 Hooks 实现 Codex Hook 契约,因此 是其事件、载荷字段与决策结构的参考来源,不要另起炉灶。[`docs/features/agent-hooks.zh-CN.md`](docs/features/agent-hooks.zh-CN.md)([English](docs/features/agent-hooks.md))只覆盖 BitFun 特有部分 —— 文件位置、`app.hooks` 开关和差异表 —— 新增或消除差异时必须同步更新。 - 可移植引擎(配置解析、载荷构造、进程执行、决策合并)位于 `bitfun-agent-runtime::native_hooks`。`bitfun-core::native_hooks` 负责配置发现、开关门控和按事件的分发辅助函数;各分发点调用这些辅助函数,不要就地执行 Hook。 -- 有三类不同的东西共用 "hook" 一词:本文所述的原生用户 Hooks、内部编译期 `post_call_hooks`,以及其他 AI 应用的只读外部 Hook 目录(`external_hooks`)。三者必须保持区分。 +- 可执行 Hooks 可以来自原生用户配置、生态插件或 BitFun 内置实现(包括当前编译期 `post_call_hooks`)。这些来源保持各自的信任、配置、契约和执行策略语义,但可以统一注册到共享的 `HookRegistry`,并由 `AgentHookEngine` 调度。其他 AI 应用的外部 Hook 目录(`external_hooks`)仍只用于只读发现,不得进入可执行 Registry。 ## 架构 diff --git a/AGENTS.md b/AGENTS.md index c13f3c3330..08da9a7f99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,9 +260,9 @@ existing installs working without manual repair. ### Agent hooks -- BitFun implements the Codex hook contract, so is the reference for events, payload fields, and the decision schema. Do not fork that contract. [`docs/features/agent-hooks.md`](docs/features/agent-hooks.md) ([中文](docs/features/agent-hooks.zh-CN.md)) covers only the BitFun-specific parts — file locations, the `app.hooks` gates, and the deviations table — and must be updated whenever a deviation is added or closed. +- BitFun native user hooks implement the Codex hook contract, so is the reference for their events, payload fields, and decision schema. Do not fork that contract. [`docs/features/agent-hooks.md`](docs/features/agent-hooks.md) ([中文](docs/features/agent-hooks.zh-CN.md)) covers only the BitFun-specific parts — file locations, the `app.hooks` gates, and the deviations table — and must be updated whenever a deviation is added or closed. - The portable engine (settings parsing, payload construction, process execution, decision merging) lives in `bitfun-agent-runtime::native_hooks`. `bitfun-core::native_hooks` owns config discovery, gating, and per-event dispatch helpers; dispatch sites call those helpers instead of executing hooks inline. -- Three separate things share the word "hook": these native user hooks, the internal compiled-in `post_call_hooks`, and the read-only external hook catalog of other AI applications (`external_hooks`). Keep them separate. +- Executable hooks may come from native user configuration, ecosystem plugins, or BitFun built-ins (including the current compiled-in `post_call_hooks`). These sources keep distinct trust, configuration, contract, and execution-policy semantics, but may register through the shared `HookRegistry` and be dispatched by `AgentHookEngine`. The external hook catalog of other AI applications (`external_hooks`) remains read-only discovery data and must not enter the executable registry. ## Architecture diff --git a/Cargo.lock b/Cargo.lock index 903dde7d3a..b53a62172d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1294,7 +1294,9 @@ dependencies = [ name = "bitfun-opencode-plugin-host" version = "0.2.19" dependencies = [ + "async-trait", "base64 0.22.1", + "bitfun-runtime-ports", "bitfun-services-core", "log", "rand 0.8.7", diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index f6d529a732..845bc53ba7 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -89,6 +89,49 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | OC-E2 | 一个真实 package plugin,仅实现其需要的 loader 和最小 client/context | 全部 loader fallback 和 Client API | | OC-E3 | 按阻塞样例加入 Hook;TUI 先接 command/slash/key,toast 需先有 CLI 类型明确的状态/通知模块 | 原始 renderer、Server、Remote、连接器 | +### 2.1 当前受管 package-plugin 运行切片 + +当前生产路径已经包含一个受管 Bun Plugin Host,用于执行配置中显式声明的 OpenCode Server plugin。Desktop、CLI 和 +app-server 复用同一条 Core 装配路径;每个本地 Session 在创建前按实际 execution root 确保对应逻辑实例,Remote +Session 不在控制机回退执行插件。一个物理 Host 可以承载多个目录实例,实例和贡献都由 +`instance_id + generation_key + revision` 隔离。 + +Desktop、CLI 和 app-server 的标准开发/构建命令都会先构建 `extension-host.js`;Desktop 包、CLI 产品包和 +app-server release 输出都把它放入 `resources/ext-host`。当前分发仍要求系统提供兼容的 `bun`(也可通过 +`BITFUN_BUN_COMMAND` 显式指定);签名 Bun +sidecar 尚未交付,因此安装包还不能宣称插件运行时完全自包含。恢复 Session 和 Host 崩溃后的同 Session 下一次提交 +都会重新 ensure 实际 execution root;Remote Session 继续保持不在控制机执行本地插件。 + +Rust 侧只暴露一个类型化 `HookFunctionRuntime` 数据面,覆盖启动及完整注册快照、`tool.execute.before/after`、插件 +Tool 执行与取消、dispose,以及插件反向调用的 metadata/ask。OpenCode wire JSON 和 RPC lease 留在 adapter 内, +当前 package-plugin 的逻辑代际与诊断由 Core lifecycle 接入既有 owner/read-only surface;`PluginRuntimeClient` 只继续 +服务 legacy managed-package 路径。`tool.execute.before` 先于 native `PreToolUse`,两者完成后 +重新做 schema 和权限判断;Tool 只执行一次,`tool.execute.after` 再先于 native `PostToolUse`。运行型插件 Hook +失败会停止当前有序 Hook 链;after 失败只把已执行结果标成错误并反馈给模型,不进入 Tool 重试。 + +Config Hook 的输入复用 adapter 现有本地来源计划,按 user global、显式文件、project、配置目录和 inline 的顺序 +合并完整 JSON/JSONC 对象;`$schema` 不是必填项,未知字段原样保留。Config Hook、插件 Agent/权限/Skill 投影、Tool +注册和模型可见 output 已接入现有归属模块。插件 Tool 的真实 `title`、`output`、`metadata` 会进入完整 after Hook 链, +原始结构化结果继续保留这些字段;after Hook 对 title/metadata 的变换尚无稳定 UI/持久化消费方,当前只把变换后的 +output 作为模型展示结果,后续有真实消费方时再扩展小型展示契约。 + +插件 Host 初始化失败发布全局诊断;已知 workspace 的创建/激活失败发布可从对应 external-source snapshot 查询的 +`plugin.activation_failed`,并继续原生 Agent Session。existing-session ensure 的诊断归属限制见第 6 节。初次激活失败时插件贡献不可用;刷新失败时已确认的 +上一代贡献可能继续服务,诊断只表示本次激活/刷新失败,成功 ensure 后清除。插件 Tool 结束会同步取消其反向 ask +所创建的待审批请求,避免执行路由撤下后遗留权限状态。 + +当前信任边界是“配置中显式声明的插件等同受信任本地可执行扩展”。此前的 activation review/approve 接口没有进入 +执行门禁,继续保留会让调用方误以为已有安全保证,现已删除。此切片保持完整功能,但不满足本文第 7 节的安全完成 +判定;后续安全 PR 必须在不改变 `HookFunctionRuntime` 业务语义的前提下补齐: + +- import 前不可变内容快照和 TOCTOU 校验; +- 来源签名、provenance 与安装/更新完整性; +- 首次启用和扩权确认、细粒度权限及可撤销激活状态; +- 凭据隔离、环境变量最小化和敏感数据审计; +- 每插件 worker/沙箱选型,以及 CPU、内存、网络、子进程和队列额度。 + +现有进程树回收、代际 fencing、期限、取消和 Host 故障重启仍是功能可靠性控制,不代表已经实现恶意插件隔离。 + ## 3. 能力矩阵 `当前状态`只表示 OpenCode 兼容行为是否已经进入 BitFun 生产路径,不把“BitFun 有相似基础模块”算成已兼容。 @@ -130,35 +173,33 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 |---|---|---|---|---|---|---| | `.opencode/tools/*.js` | 补基础能力 | 受支持单文件子集已接入 Tool Runtime | 可完整适配 | OC-R2 | 当前 Node worker 支持基础 schema、默认值、字符串结果、取消/超时/撤下;完整 Zod、模块依赖、`metadata`/`ask` 和附件结果继续走类型化进程通信扩展 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | | `.opencode/tools/*.ts` | 补基础能力 | 已识别,执行不支持 | 可完整适配 | OC-R2 | 当前静态显示不 import;后续由固定样例选择 Node 转译或 Bun/TypeScript worker,保留真实 schema 与 execute,不在 Rust 猜测 TS 语义 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | -| 插件 `tool` map | 补基础能力 + 补扩展接口 | 未实现 | 可完整适配 | OC-R2 | 运行插件工厂,按同一双表示注册真实工具,并接到 Tool 归属模块 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | -| 项目与用户目录插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 直接发现本地 JS/TS 模块,不要求 BitFun 专用清单;来源、插件身份和执行域确认后,在旧 Host 停止后由新 Host 加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | -| 配置中的软件包插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 确认来源、插件身份和执行域后,用 npm 配置、Arborist、package-lock 和 `ignoreScripts: true` 准备依赖,再由与固定插件样例匹配的 Node/Bun 脚本执行后端加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | +| 插件 `tool` map | 补基础能力 + 补扩展接口 | 已实现:受管 Host 注册、执行、取消和 generation 隔离 | 可完整适配 | OC-R2 | 真实 title/output/metadata 已传给 after Hook;附件保留在结构化结果中,但通用文件/模型消费等待真实产品消费方 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | +| 项目与用户目录插件 | 补基础能力 | 部分实现:配置显式声明的本地文件/目录可执行,自动目录发现未实现 | 可完整适配 | OC-R2 | 补完整来源顺序下的自动发现与状态 UX;运行继续复用受管 Host | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | +| 配置中的软件包插件 | 补基础能力 | 已实现:受管缓存准备、禁用 lifecycle scripts、Bun Host 加载 | 可完整适配 | OC-R2 | 安装/更新 provenance、不可变快照和激活权限按 2.1 的安全后续项补齐 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | | 全局插件加载 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 自动发现全局配置和 ConfigPaths 全局目录,并按完整来源顺序生成 `plugin_origins`;首次可执行启用按来源、插件身份和执行域确认,决定只提示一次且可按项目覆盖 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | -| `package.json`、入口与依赖 | 补基础能力 | 未实现 | 可主要适配 | OC-R2 | 复现 server 入口、入口回退、`engines.opencode`、npm 配置和锁文件;原生模块失败只影响对应插件 | [来源与执行版本](opencode-plugin-runtime-adapter-design.md#4-来源与执行版本) | +| `package.json`、入口与依赖 | 补基础能力 | 主要实现:server exports/main/入口回退、`engines.opencode`、受管依赖准备,以及本地 package/source tree 内容摘要 | 可主要适配 | OC-R2 | 摘要变化会产生新逻辑 generation;物理 Host 更新边界见第 6 节。继续补 npm 配置和原生模块兼容样例 | [来源与执行版本](opencode-plugin-runtime-adapter-design.md#4-来源与执行版本) | | 内置/MCP/外部同名工具;后续 pure/重复插件顺序 | 融合现有能力 | standalone Tool 显式选择已实现 | 可完整适配 | OC-R2 | 当前按候选身份与内容版本记忆选择且不静默覆盖;package plugin 阶段再复现 internal-first、pure、来源顺序和去重 | [注册与覆盖](opencode-plugin-runtime-adapter-design.md#53-注册与覆盖) | -| `project` / `directory` / `worktree` | 直接桥接 | standalone Tool 已传 `directory/worktree/sessionID`;完整 `project` 未实现 | 可完整适配 | OC-R2 | 当前 `directory` 为打开的 workspace、`worktree` 为 Git 根并传递真实 session;完整插件 `project` 和 Remote 在 OC-R5 前保持 `unsupported` | [插件兼容接口](opencode-plugin-runtime-adapter-design.md#7-opencode-插件兼容接口) | -| `client` | 补扩展接口 | 未实现 | 可主要适配 | OC-R2 | 提供版本化插件客户端接口,按方法转发到现有 BitFun 归属模块 | [插件兼容接口](opencode-plugin-runtime-adapter-design.md#7-opencode-插件兼容接口) | -| `serverUrl` | 补扩展接口 | 未实现 | 可主要适配 | OC-R2 | 在 Plugin Host 执行域提供真实回环服务,只实现插件所需的版本化路由 | [插件兼容接口](opencode-plugin-runtime-adapter-design.md#7-opencode-插件兼容接口) | -| `$` 与脚本环境 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 只有需要 OpenCode/Bun `$` 的固定样例才启用 Bun-compatible adapter;Node 路径不能伪造等价语义。受限模式依赖真实 OS/容器边界,无法落实时停用插件 | [默认策略](opencode-plugin-runtime-adapter-design.md#3-默认策略与可调权限) | -| 加载、停用、更新与崩溃恢复 | 补基础能力 | standalone Tool 加载失败即撤下脚本 | 可主要适配 | OC-R2 | 已有来源限定身份、后台重载、删除撤下与 worker 终止;package plugin 还需共享 Plugin Host、安全重启和进程级恢复 | [生命周期](opencode-plugin-runtime-adapter-design.md#9-生命周期) | -| 同一 Host 内未文档化全局共享 | 明确限制 | 未实现 | 可主要适配 | OC-R2 | package plugin 默认共享 Plugin Host,但不把 `globalThis`、进程环境或模块单例协作提升为稳定兼容承诺 | [故障域](opencode-plugin-runtime-adapter-design.md#81-故障域) | +| `project` / `directory` / `worktree` | 直接桥接 | 已实现:受管实例和 Tool context 使用真实本地 execution root;Remote 明确不回退 | 可完整适配 | OC-R2 | 继续补固定多工作树/多 Session 样例 | [插件兼容接口](opencode-plugin-runtime-adapter-design.md#7-opencode-插件兼容接口) | +| `client` | 补扩展接口 | 主要实现:Plugin 所需方法经实例回环 gateway 转发现有后端 owner | 可主要适配 | OC-R2 | 按真实插件补方法;未知写操作稳定失败 | [插件兼容接口](opencode-plugin-runtime-adapter-design.md#7-opencode-插件兼容接口) | +| `serverUrl` | 补扩展接口 | 已实现:每实例独立 loopback gateway,支持流式 HTTP/SSE | 可主要适配 | OC-R2 | WebSocket 明确不支持;完整外部 Server 协议不在本切片 | [插件兼容接口](opencode-plugin-runtime-adapter-design.md#7-opencode-插件兼容接口) | +| `$` 与脚本环境 | 补基础能力 | 已实现:受管 Bun Host 注入公开 `$` 能力 | 可完整适配 | OC-R2 | 受限模式仍依赖后续真实 OS/容器安全边界 | [默认策略](opencode-plugin-runtime-adapter-design.md#3-默认策略与可调权限) | +| 加载、停用、更新与崩溃恢复 | 补基础能力 | 主要实现:共享 Host、逻辑实例 dispose、代际 fencing、崩溃进程树回收、失败诊断和下一次 ensure 重启;插件失败不阻断原生 Session | 可主要适配 | OC-R2 | 正常更新/停用的物理 Host generation 替换、不可变旧版本恢复和安全更新策略按第 6 节后续项补齐 | [生命周期](opencode-plugin-runtime-adapter-design.md#9-生命周期) | +| 同一 Host 内未文档化全局共享 | 明确限制 | 已实现共享 Host,但不承诺未文档化全局协作 | 可主要适配 | OC-R2 | 保留公开 PluginInput、Hook 顺序和显式接口 | [故障域](opencode-plugin-runtime-adapter-design.md#81-故障域) | 本类整体风险是第三方代码副作用、依赖安装失败、Hook 顺序不一致和 Plugin Host 失控。默认权限可以开放,但 Rust 主应用与 Plugin Host 的进程隔离、超时、取消、队列上限、结果大小和故障恢复必须始终启用。 ### 3.3 稳定服务 Hook -本节的“实现”指进入真实 OpenCode 插件运行时。BitFun 当前按插件声明与具名导出顺序,从本地插件文件静态展示 -下列 Hook 属性,并把 `tool.execute.before/after` 映射到已有 Tool Hook 点;未知或动态注册保持 `opaque`。映射仅表示 -BitFun 已识别等价契约覆盖,不表示外部 handler 已加载、激活或执行。目录不会 import 或执行插件,内容版本也只 -内容摘要化脱敏后的目录事实,因此不改变任何 Hook Runtime 的“未实现”结论。`tool` 是工具注册能力,不作为 Hook -事件猜测或静态显示。 +本节的“实现”指进入真实 OpenCode 插件运行时。静态 Hook catalog 仍只负责 runtime-free 发现;配置中显式声明的 +package plugin 则由受管 Host import,并通过类型化 `HookFunctionRuntime` 执行。两条路径的状态必须分别显示,静态 +发现不能冒充运行可用,运行 Host 也不能反向接管其他生态的静态目录。 | Hook | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | |---|---|---|---|---|---| -| `dispose` | 直接桥接 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 调用清理并设置期限;超时回收 worker。 | +| `dispose` | 直接桥接 | 已实现:有界清理;drain/dispose 超时使 Host 代际失效并回收进程树 | 可完整适配 | OC-R3 | 补真实阻塞插件的跨平台 Host 重启样例。 | | `event` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 提供版本化事件代理并隔离插件异常。 | -| `config` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 按插件顺序变换,最后由 Config 归属模块校验提交。 | -| `tool` | 补基础能力 + 补扩展接口 | 未实现 | 可完整适配 | OC-R2 | 注册真实工具定义与执行函数。 | +| `config` | 补扩展接口 + 融合现有能力 | 已实现:完整本地合并配置作为输入,按插件顺序执行;单个失败 Hook 回滚其修改并继续,成功结果投影 Agent、权限、Tool、workspace Skill | 可完整适配 | OC-R3 | 继续由各归属模块做最终校验;生态字段解析迁移边界见第 6 节。 | +| `tool` | 补基础能力 + 补扩展接口 | 已实现:真实定义、执行、取消和 generation-fenced 路由 | 可完整适配 | OC-R2 | 附件结果等待产品消费方。 | | `auth` | 补扩展接口 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 提供 API/OAuth 方法和脱敏凭据代理。 | | `provider` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 将动态模型列表接入 Provider 归属模块。 | | `chat.message` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换消息和 parts,变换后重做结构校验。 | @@ -166,9 +207,9 @@ BitFun 已识别等价契约覆盖,不表示外部 handler 已加载、激活 | `chat.headers` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换请求头,敏感值不进入日志。 | | `permission.ask` | 融合现有能力 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 默认保留 allow/deny/ask 语义;用户或组织策略可收紧。 | | `command.execute.before` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 在命令执行前依次变换消息 parts。 | -| `tool.execute.before` | 补扩展接口 | 静态映射可见,运行未实现 | 可完整适配 | OC-R3 | 变换最终参数,随后重做 schema 和权限判断。 | +| `tool.execute.before` | 补扩展接口 | 已实现:先于 native PreToolUse,之后重做 schema 和权限判断 | 可完整适配 | OC-R3 | 补完整端到端 fixture 与交互验证。 | | `shell.env` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 在实际执行域构造环境变量。 | -| `tool.execute.after` | 补扩展接口 | 静态映射可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换 title、output、metadata,保留原始结果引用。 | +| `tool.execute.after` | 补扩展接口 | 已实现:有序变换并保留原始结果;最终 model-visible output 已消费 | 可完整适配 | OC-R3 | title/metadata 等待稳定 UI/持久化消费方,见 2.1。 | | `tool.definition` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 变换模型可见 JSON Schema;真实执行继续使用 worker 中原始 Zod 校验,保持 OpenCode 双表示语义。 | Hook 的共同风险是把变换误做成通知、并行调用破坏顺序或插件写入非法状态。所有 Hook 都走类型化调用、顺序执行和归属模块终检;具体调用协议见[服务插件运行时设计](opencode-plugin-runtime-adapter-design.md#6-钩子适配与权威提交)。 @@ -338,6 +379,16 @@ Node 进程永久累积;这不是 package-plugin 的 workspace-scoped runtime | `experimental_workspace.register` | 暂不承诺 | 接口未稳定且会改变工作区与远程连接归属 | 继续使用 BitFun Workspace/Remote 归属模块,稳定后重评。 | | 受限策略下拦截任意脚本副作用 | 只能部分控制 | 插件可以直接调用脚本运行时,绕过细粒度能力代理 | 来源激活后默认兼容策略放开;用户收紧时明确列出被禁用或无法拦截的能力。 | | 无硬资源限制平台上的系统资源耗尽 | 不能保证完全隔离 | 已有进程树可回收受管后代,但仍不能阻止内存、CPU、网络或逃逸进程拖慢整机 | 在真实需求下增加 cgroup/rlimit/容器等平台额度;缺少硬限制时显示残余风险。 | +| Plugin Tool 通用附件消费 | 后续产品 PR | 当前已保留 OpenCode attachment 的 mime/url/filename,尚无跨模型、文件 UI 与持久化共同认可的消费合同 | 本 PR 不把已完成的副作用误报为失败;后续由真实消费方定义文件授权、下载/读取、Remote 和模型可见规则。 | +| Plugin Config Skill 在 `/skill` 目录与批量管理中的展示 | 后续体验 PR | 当前 workspace Skill 根已进入 Agent 上下文、隐式发现和 Skill Tool 精确加载;Desktop/CLI 的 mode Skill 目录仍只扫描常规来源,因此插件 Skill 暂不出现在下拉选择和批量管理中 | 有明确产品需求时复用 Skill Registry 现有候选合并步骤,让 mode/all-skills 查询共享同一来源集合,并补 Desktop/CLI 生产 consumer 测试;不新增 Skill catalog 或扫描框架。 | +| 正常更新或停用时替换物理 Host generation | 后续生命周期 PR | 当前内容/依赖变化会形成新逻辑 generation,旧 Hook、Tool、Agent、Skill 会撤下并调用 `dispose`;共享 Bun Host 仅在崩溃、不可确认取消、应用退出时回收完整进程树。未正确实现 `dispose` 的 import 期 timer、连接或子进程可能继续存活,依赖模块缓存也可能保持到物理 Host 重启 | 在共享 Host owner 增加一次进程级 drain/stop/确认/重启,撤下旧 Host 的全部 workspace generation,再按使用恢复仍启用的 workspace;不增加 per-plugin Runtime 或热迁移状态机。 | +| OS 级强制终止本身失败 | 后续可靠性 PR | 这是极端平台故障;当前代际会撤下贡献并报告故障,但缺少“确认进程死亡前永久禁止同进程重启”的持久 poisoned gate | 增加可验证的进程存活探测和 sticky poisoned 状态;只有确认旧树死亡或应用重启后才允许新 Host,覆盖 Windows/Unix 故障注入。 | +| 代际替换与长时 backend 写请求精确并发 | 后续可靠性 PR | 当前会停止新 Hook/Tool、取消 instance stream 并 dispose,但尚无 instance-scoped backend RPC admission/drain;极端并发下旧代已接收的写请求可能晚到完成 | 在 backend bridge 增加 instance-scoped 拒绝新请求和有界 drain;超时标记 OutcomeUnknown,且不得确认 replacement 完成。 | +| 多 workspace 同名 Tool 激活与退役精确并发 | 后续可靠性 PR | 当前 mux 路由与全局注册表分别受锁保护;极端的最后一条旧路由退役和新路由激活交错时,可能短暂撤下仍有新路由的 mux | 统一两层状态的锁序或保留空 mux,并增加同名 Tool 激活/退役并发测试;常规顺序切换和 workspace 隔离已由本 PR 覆盖。 | +| workspace 在扫描前已被删除或移动 | 后续清理 PR | workspace 路径无法规范化时不会猜测等价身份;已激活实例可能保留到显式停用或应用退出 | 保存已确认的 canonical identity,并在来源撤销通知中按该 identity 退役;补删除、移动和符号链接变化样例。 | +| Core 内遗留的 OpenCode Config 投影细节 | 后续边界收敛 PR | 当前 Core 正确拥有 generation 原子提交及 Agent/权限/Skill owner 对接,但仍直接解释 `agent.mode/hidden/temperature/prompt/permission` 与 `skills.paths`,形成明确的临时模块边界债务 | 由 OpenCode adapter 输出最小的类型化 Agent、权限约束和 workspace Skill root 投影;Core 只做现有 owner 校验、runtime key 绑定与原子提交。不得新增通用 Config 平台或第二套 Runtime。 | +| existing-session 激活失败的诊断归属 | 后续诊断 PR | 当前 create-session 失败按 workspace 记录;已有 Session 的 ensure 失败缺少已解析 execution root,可能显示为全局诊断,但不会改变原生 Session/Turn 结果 | 让 ensure 返回 workspace 与错误的组合,并补 workspace A 失败不污染 workspace B 的状态测试;不改变插件激活或执行语义。 | +| existing-session 每次 ensure 的 prepare 成本 | 后续性能 PR | 当前每个恢复触发点都会重新 prepare,再按稳定摘要复用实例;结果正确,但大本地源码树会增加文件扫描和一次 Host RPC | 在来源 watcher/config revision 已有事实之上增加健康 generation 快路径;失去健康或版本事实时仍执行完整 prepare,不使用固定 TTL 猜测。 | 这些限制已经作为当前架构决策:项目状态只能表述为“兼容矩阵已审计、已实现项按证据列示”,不能表述为“稳定 扩展面已完整实现”或“所有插件完整兼容”。只有真实需求和新证据可以重新开启延期项。 diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index fab35c825f..e5493d1d24 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -18,7 +18,7 @@ |---|---| | Plugin Host | 运行 Bun 与第三方 JS/TS 插件的受监督子进程;Host 不在 Rust 主应用进程内 | | `PluginRuntimeClient` | Rust 主应用内部现有调用端口;校验请求和响应,管理超时、同一插件的串行调用、重复请求结果缓存、诊断与故障隔离 | -| `ScriptToolRuntime` / `NodeScriptToolRuntime` | 现有脚本执行端口及 services 实现;当前负责 standalone Tool worker,后续 Plugin Host 的物理进程职责也应沿此边界扩展 | +| `ScriptToolRuntime` / `NodeScriptToolRuntime` | 现有脚本执行端口及 services 实现;只负责 standalone Tool worker,不拥有共享 Plugin Host | | 插件实例 | 由来源、插件身份和当前内容版本确定的已启用插件;启停事实仍由现有来源与能力模块管理 | | contribution | Tool、Hook、Command、Route 或界面项等对外行为;由对应能力归属模块注册和提交 | @@ -26,32 +26,39 @@ workspace、project、session、turn、run 和 working directory 是不同事实 决定 Plugin Host 进程数量。只有某项并发或权威状态确实要求单一实例时,负责该状态的归属模块才能把 workspace 或其他身份加入自己的状态键,并说明清理与迁移语义。 -当前产品运行路径不执行第三方 package 插件。仓库中的 Bun Host、RPC 和 OpenCode 适配代码是协议与进程隔离基础, -由 fixture/mock 验证;Desktop 与 CLI 的自动启动策略保持关闭。在 contribution 归属、执行许可和故障恢复接入既有 -Tool、Config、Permission、Session、Event、TUI 等模块之前,不得把这套基础设施视为已交付的插件执行能力。 +当前本地生产路径可执行配置中显式声明的第三方 OpenCode package plugin。Desktop、CLI、Server/App Server 的 Runtime +入口按真实 execution root 确保 workspace 逻辑实例,复用所在 Rust Runtime 进程监督的共享 Bun Host;Remote +execution domain 不回退到控制机执行。Tool、Config、Permission、Agent/Skill 和有序 Hook 只通过既有归属模块提交, +其余兼容项仍按能力矩阵标记为未实现,不能由这条运行切片外推成完整 OpenCode Runtime。 ## 2. 职责 ```mermaid flowchart LR Owners["能力归属模块"] - Client["PluginRuntimeClient"] + Lifecycle["Core package lifecycle"] + Runtime["HookFunctionRuntime"] Adapter["生态适配器"] Service["Process service"] Host["Plugin Host\nBun"] + Legacy["legacy managed package"] + Client["PluginRuntimeClient"] - Owners <--> Client - Client <--> Adapter + Owners <--> Lifecycle + Lifecycle <--> Runtime + Runtime <--> Adapter Adapter <--> Service Service <--> Host + Legacy <--> Client ``` | 部分 | 负责 | 不负责 | |---|---|---| -| `PluginRuntimeClient` | 当前校验请求和响应;管理超时、同一插件的串行调用、重复请求结果缓存、诊断与故障隔离。目标再增加队列上限、取消后的结果失效,并拒绝旧 Host 的结果 | 运行 JS/TS、持有 OS 进程、决定来源顺序或提交业务状态 | -| 生态适配器 | 保留对应生态的加载顺序、参数、结果、错误和 Hook 语义 | 创建跨生态最低公分母或成为第二个业务归属模块 | -| `ScriptToolRuntime` 与 services 实现 | 当前启停 standalone worker;目标态沿同一 services 边界启停 Plugin Host,并持有完整进程树、资源预算、物理健康、IPC 和强制回收 | 解释 Hook、决定权限或保存插件业务状态;不得把 Rust 侧实现命名为 Host | -| Plugin Host | 加载真实模块,保存进程内模块实例,按适配协议执行 Plugin/Hook/Tool/Client 调用 | 成为第二个 Agent Runtime、写入 Rust 归属模块的权威状态或决定产品策略 | +| `PluginRuntimeClient` | legacy managed-package 路径的请求校验、超时、串行调用、重复结果与诊断 | 当前 package-plugin Host 生命周期、运行 JS/TS、决定来源顺序或提交业务状态 | +| Core package lifecycle / `HookFunctionRuntime` | 当前 package-plugin workspace 逻辑实例、代际提交、owner 对接和类型化调用 | Plugin Host wire、进程句柄或 OpenCode 原始语义 | +| OpenCode 生态适配器 | 保留加载顺序、Config/Hook/Tool 参数、结果和错误语义;持有共享 Plugin Host 的连接和物理生命周期 | 创建跨生态最低公分母或成为第二个业务归属模块 | +| `ScriptToolRuntime` 与 services 实现 | 启停 standalone worker;通用进程树原语也供 Plugin Host adapter 使用 | 解释 Hook、决定权限、保存插件业务状态或拥有共享 Host 生命周期 | +| Plugin Host | 监督 Bun 子进程、加载真实模块、保存进程内模块实例,按适配协议执行 Plugin/Hook/Tool/Client 调用,并通过 services 进程树原语回收受管后代 | 成为第二个 Agent Runtime、写入 Rust 归属模块的权威状态或决定产品策略 | | 能力归属模块 | 校验并提交 Tool、Hook 变换、配置、权限、会话、事件和界面贡献 | 直接加载第三方模块或管理 Plugin Host 进程 | 来源发现、用户选择和当前启用版本继续由各自已有归属模块管理;`PluginRuntimeClient` 只使用已经允许执行的插件实例, @@ -150,6 +157,11 @@ Rust 监督路径检查,不能依赖可能已被同步插件代码阻塞的业 插件 import 可能立即启动后台任务或产生文件、网络和进程副作用。因此新旧 Plugin Host 不能同时加载同一组插件。 旧 Host 服务期间只能做不执行插件代码的来源、完整性、依赖和策略检查;真正加载新代码需要一个短暂停机窗口。 +本节描述完整生命周期目标。当前 OC-R2 可用切片已实现内容摘要、逻辑 generation 撤下、崩溃后的进程树回收与 +下一次使用恢复;正常源码更新、配置停用时的共享 Host 物理 generation 替换仍按兼容矩阵第 6 节作为后续生命周期 +工作跟踪。在该项完成前,更新后的贡献不会与旧逻辑 generation 并行发布,但 import 期创建且未被插件 `dispose` +清理的进程内副作用可能持续到 Host 崩溃或应用退出,因此当前状态不能表述为已完成安全热更新。 + ```mermaid flowchart LR Change["Source changed"] --> Check["Static checks"] --> Ready["Ready to restart"] @@ -215,8 +227,8 @@ flowchart LR |---|---|---| | 来源、用户选择、执行许可和内容摘要 | 外部来源与安全归属模块 | 重新读取,不由 Host 猜测 | | 当前内容版本与贡献注册 | 对应来源/能力归属模块 | 重新读取内容版本;完整重载并校验后再发布 | -| 重复请求结果和故障诊断 | `PluginRuntimeClient` 及只读诊断视图 | 按明确恢复条件清理,不由新进程静默抹除 | -| 子进程句柄、IPC 连接、物理健康和重启预算 | `ScriptToolRuntime` 所在的 services 实现 | 同一进程故障只消费一次进程级重启预算 | +| 重复请求结果和故障诊断 | 当前 package 路径由 Core lifecycle 与 OpenCode adapter 共同生成并投影到只读诊断;legacy managed 路径由 `PluginRuntimeClient` 持有 | 按明确恢复条件清理,不由新进程静默抹除 | +| 子进程句柄、IPC 连接、物理健康和重启预算 | package Host 由 OpenCode adapter 持有并复用 services 进程树原语;standalone worker 由 `ScriptToolRuntime` 持有 | 同一进程故障只消费一次进程级重启预算 | | 模块实例、`globalThis`、闭包和内存缓存 | Plugin Host | 易失;不复制、不持久化,也不承诺恢复 | | Tool/Hook/Config/Permission/Session 最终状态 | 各能力归属模块 | 不从 Host 内存反向恢复 | @@ -254,32 +266,23 @@ Windows 使用 Job Object,Unix 至少使用独立 process group 管理完整 ```mermaid flowchart LR - subgraph Current["current implementation"] - Manifest["Plugin manifest"] --> Static["Static preview"] - Script["Standalone .js tool"] --> Worker["Dedicated worker"] - Fixture["Protocol fixtures"] --> Foundation["Bun Host foundation"] - end - - subgraph Planned["planned runtime"] - Package["Package plugins"] --> Client["PluginRuntimeClient"] - Client --> Adapter["生态适配器"] - Adapter --> Service["Process service"] - Service --> Shared["Shared Plugin Host"] - end - - Static -. "not executable" .-> Package - Worker -. "narrow slice" .-> Service + Config["显式配置的 package plugin"] --> Assembly["Core lifecycle assembly"] + Assembly --> Host["Shared Bun Plugin Host"] + Host --> Runtime["typed HookFunctionRuntime"] + Runtime --> Owners["Tool / Hook / Config / Permission / Agent / Skill owners"] + Script["Standalone .js tool"] --> Worker["Dedicated Node worker"] ``` -当前受管 `bitfun.plugin.json` 链路仍只有来源校验、启停记录、CLI 诊断和 custom tool 静态预览,不执行 package -plugin、Hook、完整 Client 或 TUI 插件入口。与其独立的 standalone `.js` Tool 端到端能力当前由 -`ScriptToolRuntime` 为每个脚本启动 Node worker;这是现有窄实现事实,不是目标 package-plugin 的进程模型。 -Bun Host 基础设施仅覆盖模块加载、RPC/HTTP 桥和进程树生命周期等隔离边界;配置插件时 CLI 会明确报告该执行链路 -尚未启用,不会静默导入或运行插件代码。 +当前 package-plugin 切片会准备并加载配置中显式声明的插件,发布 generation-fenced 注册快照,并把完整合并配置、 +Config Hook、Tool、`tool.execute.before/after`、反向 metadata/ask 和最小 Client gateway 接入现有 owner。一个 Rust +Runtime 进程监督一个物理 Host,Host 可承载多个 workspace 逻辑实例;Session/Tool 调用只携带上下文,不成为进程键。 +已知 workspace 的创建/激活失败会发布对应范围可查询的 `plugin.activation_failed` 并继续原生 Session; +existing-session ensure 暂缺 execution root 时的诊断可能进入全局范围,按兼容文档第 6 节跟踪。初次激活失败时没有 +插件贡献;刷新失败可保留已确认的上一代贡献,因此诊断表达“本次激活/刷新失败”,不能把原生路径成功写成插件激活成功。 -因此当前代码不得声称已经具备共享 Plugin Host、安全重启、通用进程级恢复或 Bun 兼容。目标实现应先用 -固定 OpenCode fixture 验证多个插件的顺序初始化、Hook 顺序、共享进程崩溃、安全重启和状态恢复,再替换现有 -窄执行路径。 +standalone `.js` Tool 继续由 `ScriptToolRuntime` 为每个脚本启动 Node worker,两条执行路径不共享生命周期对象。当前 +分发仍要求系统 Bun 或 `BITFUN_BUN_COMMAND`,自动目录发现、完整 OpenCode Client/Hook/TUI 表面、安全启用门禁、 +不可变旧版本恢复和资源沙箱仍未完成;共享 Host 进程隔离也不承诺插件间隔离。 ## 8. 验证要求 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index b53cc421df..95bf3d2bac 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -311,6 +311,7 @@ flowchart LR SharedRuntime["Shared Runtime"] WorkspaceData["Workspace Data"] ToolProcesses["Tool Processes"] + PluginHosts["Plugin Host(s)\noptional Bun child"] end subgraph UserDevice["Client Device"] @@ -338,6 +339,7 @@ flowchart LR CLIApp -.->|Local IPC| SharedRuntime WebServer --> WorkspaceData WebServer -->|spawn| ToolProcesses + WebServer -->|optional spawn| PluginHosts WebServer -->|HTTPS| AIProviders RelayServer --> RelayDB RelayServer --> AssetStore @@ -345,19 +347,21 @@ flowchart LR SharedRuntime --> WorkspaceData EmbeddedNodes -->|spawn| ToolProcesses SharedRuntime -->|spawn| ToolProcesses + EmbeddedNodes -->|optional spawn| PluginHosts + SharedRuntime -->|optional spawn| PluginHosts EmbeddedNodes -->|HTTPS| AIProviders SharedRuntime -->|HTTPS| AIProviders DesktopApp -->|SSH| RemoteHosts classDef unit fill:#ffffff,stroke:#737373,stroke-width:1.3px,color:#171717; - class DesktopApp,CLIApp,ACPApp,SDKHost,SharedRuntime,WorkspaceData,ToolProcesses,WebClient,MobileClient,WebServer,RelayServer,RelayDB,AssetStore,AIProviders,RemoteHosts unit; + class DesktopApp,CLIApp,ACPApp,SDKHost,SharedRuntime,WorkspaceData,ToolProcesses,PluginHosts,WebClient,MobileClient,WebServer,RelayServer,RelayDB,AssetStore,AIProviders,RemoteHosts unit; style LocalHost fill:#ffffff,stroke:#737373; style EmbeddedNodes fill:#ffffff,stroke:#a3a3a3; style UserDevice fill:#ffffff,stroke:#a3a3a3; style RelayHost fill:#ffffff,stroke:#737373; ``` -实线表示主要协议、存储访问或进程创建,虚线表示显式启用的 Shared TUI 本机连接。Relay DB 只在账户模式启用,Asset Store 的具体实现由部署配置选择。完整 package plugin 尚未形成生产闭环,因此不把规划中的 Plugin Host 画成当前部署实例。 +实线表示主要协议、存储访问或进程创建,虚线表示显式启用的 Shared TUI 本机连接。Relay DB 只在账户模式启用,Asset Store 的具体实现由部署配置选择。启用配置型 OpenCode package plugin 时,实际承载 Agent Runtime 的本地 Rust 进程会监督一个共享 Bun Plugin Host;Host 在进程内按 workspace execution root 建立代际隔离的逻辑实例,Remote execution domain 当前不回退到控制机执行。 | Deployment unit | Main contents | |---|---| @@ -368,6 +372,7 @@ flowchart LR | SDK Host | 私有跨进程 adapter;公开 SDK 产品尚未交付 | | Web Server | Embedded Agent Runtime、WebSocket App Server、Health/Info;当前只允许 loopback 单用户模式 | | Relay Server | WebSocket/HTTP bridge、账户与同步;不包含 Agent Runtime | +| Plugin Host | 可选的受管 Bun 子进程;执行配置中显式声明的 OpenCode package plugin,由本地 Agent Runtime 进程监督,不按 Session 或插件各启一个进程 | ### 2.5 Scenarios (+1) · Level 0 @@ -616,10 +621,12 @@ composition,而是继续通过自己的 loopback WebSocket App Server 入口 ```mermaid flowchart LR - Owner["能力归属模块"] <--> Client["PluginRuntimeClient"] - Client <--> Adapter["生态 adapter"] - Adapter <--> Service["Process service"] - Service <--> Host["Plugin Host"] + Owner["能力归属模块"] <--> Lifecycle["Core package lifecycle"] + Lifecycle <--> Runtime["HookFunctionRuntime"] + Runtime <--> Adapter["OpenCode adapter"] + Adapter <--> Service["Process-tree service"] + Service <--> Host["Shared Bun Plugin Host"] + Legacy["legacy managed / standalone path"] <--> Client["PluginRuntimeClient / ScriptToolRuntime"] ``` 插件贡献走独立的提交链,不绕过能力归属模块: @@ -645,10 +652,10 @@ flowchart LR Agent Runtime API 是一组小而明确的用例接口,不是必须实例化的总入口;adapter 可以调用对应归属模块的少量接口, 但不能访问内部状态、绕过既有编排或复制业务规则。任何入口都不直接调用 Plugin Host。 - 插件只进入扩展贡献接口,不直接写内核状态、工具结果、权限结果或审计事实。 -- Rust 主应用内只有 `PluginRuntimeClient` 及 services 层现有脚本执行实现:前者当前负责类型化调用、期限、同一插件实例 - 串行化、重复请求结果、响应校验和故障诊断;取消结果失效、有界队列和旧连接结果拒绝只有在端口具备相应身份后 - 才能作为目标能力加入。后者沿 `ScriptToolRuntime` 边界负责 Plugin Host 的物理健康、资源预算与进程树回收。Host 仅指运行 - Node/Bun 和第三方 JS/TS 的子进程;插件启停与贡献生命周期仍由既有来源和能力归属模块管理。 +- 当前 package-plugin 路径由 Core 持有 workspace 逻辑实例和贡献代际,沿类型化 `HookFunctionRuntime` 调用 + OpenCode adapter;adapter 持有共享 Bun Plugin Host 的 wire、连接和物理生命周期,并复用 services 的进程树原语。 + legacy managed-package 请求可靠性仍由 `PluginRuntimeClient` 承担,standalone `.js` Tool worker 仍由 + `ScriptToolRuntime` 承担。三者不共享生命周期对象,也不能互相外推已交付能力。 - 外部来源的 Command、Tool、Subagent、MCP 仍保留能力专属 DTO 和 owner,但它们的发现调度统一由 `ExternalSourceControlPlane` 持有;当前 Desktop/TUI/Peer 的控制事实只通过版本化的 product-domain 只读视图共享, 不复制生态 payload、界面状态机或远端专用 DTO。App Server 已注册 external-source schema、handler 和 client translation;Embedded Host @@ -670,8 +677,9 @@ flowchart LR - `Host` 首次出现或跨文档引用时必须带限定词,并表示实际承载执行或协议的进程/产品,例如 Plugin Host、SDK Host、Peer Host; 同一小节已明确指代后可简称 Host,Rust 插件调用可靠性实现不得称为 Host。 -- 插件侧只保留插件实例、能力贡献、`PluginRuntimeClient` 和 Plugin Host 四个跨文档名词;进程监督、脚本执行、 - 来源发现和能力提交直接使用已有归属模块的职责描述,不再增加平行的 Manager/Controller/Coordinator 名称。 +- 当前 package-plugin 路径只使用插件实例、能力贡献、Core package lifecycle、`HookFunctionRuntime` 和 Plugin Host; + legacy managed-package 才使用 `PluginRuntimeClient`。进程监督、脚本执行、来源发现和能力提交直接使用已有归属模块 + 的职责描述,不再增加平行的 Manager/Controller/Coordinator 名称。 - 不建立额外的插件运行对象、注册表或状态机。插件实例由现有来源模块标识,贡献由对应能力模块管理;Plugin Host 只是可以承载多个插件实例的物理进程组。 - workspace 只在具体归属模块确有独立配置、状态、版本或并发单例时作为该状态的限定键;它不是通用 @@ -686,9 +694,11 @@ flowchart LR ## 5. OpenCode-compatible 当前基线与目标 -Plugin Runtime P0 只验证了 BitFun 专用插件目录中的来源校验、工作区审核、启停记录、CLI 诊断和 custom tool 名称预览。 -它不执行 JS/TS,不注册真实工具,也不运行 OpenCode 钩子、Client 或终端插件。现有能力只能称为“静态预览”, -不能称为“OpenCode 插件运行时”。详细代码事实集中在 +当前生产路径可通过一个受管 Bun Plugin Host 执行配置中显式声明的 OpenCode package plugin,并把完整合并配置、 +Config Hook 的 Agent/权限/Skill 投影、真实 Tool 注册与执行、`tool.execute.before/after`、最小 Client 回环接口接入 +既有归属模块。插件激活失败会撤下对应代际贡献、发布统一诊断并保留原生 Session 能力;Remote execution domain +当前明确不在控制机回退执行。自动插件目录发现、完整 Client/Hook/TUI 表面与安全收口仍未完成,不能表述为完整 +OpenCode Runtime 兼容。详细代码事实集中在 [`plugin-runtime-design.md#7-当前实现`](extensions/plugin-runtime-design.md#7-当前实现)。 与 Plugin Runtime 分离的四条纵向基线已经通过各自的能力专属 provider 契约接入:Prompt Command 可发现本地 @@ -722,9 +732,8 @@ flowchart LR 稳定决策如下: - 不启动完整 OpenCode Runtime,也不依赖用户安装 OpenCode CLI;BitFun 实现自己的监督、适配和 Rust 转发层。 - 当前 standalone Tool 子集通过受监督的 Node.js worker 执行且不安装依赖;未来只有固定的 package plugin 样例证明 - 确有需要时,才单独裁决 Bun、依赖准备和版本兼容方案。OpenCode v2 当前同时维护 Bun 编译产物与 Node SEA 并行 - 产物,因此 BitFun 不把外部项目尚未稳定的运行时选择提升为插件内部 ABI 或核心架构约束。 + 当前 standalone Tool 子集继续通过受监督的 Node.js worker 执行;配置型 package plugin 使用受管 Bun Host,分发仍 + 依赖系统 Bun 或 `BITFUN_BUN_COMMAND`,Bun 只属于当前物理执行后端,不进入插件内部 ABI 或核心业务合同。 - 用户全局和项目来源自动发现;低风险内容默认无感应用并显示可撤销摘要,可执行来源首次启用或能力扩大时等待 非阻塞确认。确认前不得 import module、启动 worker、读取凭据或产生直接脚本副作用。 - 激活后的本地插件默认按 OpenCode 语义运行,允许当前用户通常拥有的文件、网络、进程和环境能力;用户、 diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 7d60d61eda..788174037c 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2725,7 +2725,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::get_available_modes", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn get_available_modes( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, request: Option, ) -> Result, String>", + "signature": "fn get_available_modes( state: State<'_, AppState>, runtime: State<'_, DesktopRuntimeContext>, startup_trace: State<'_, DesktopStartupTrace>, request: Option, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, { diff --git a/package.json b/package.json index e33c1044aa..161803bb1d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", - "test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/version-generation.test.mjs scripts/tauri-release-manifest.test.mjs", + "test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/server-build.test.mjs scripts/version-generation.test.mjs scripts/tauri-release-manifest.test.mjs", "fmt:rs": "node scripts/format-changed-rust.mjs", "lint:rs": "cargo clippy --workspace --exclude bitfun-desktop --all-targets", "lint:rs:desktop": "pnpm run prepare:mobile-web && cargo clippy -p bitfun-desktop --all-targets", @@ -86,7 +86,9 @@ "plugin-host:install": "bun install --cwd src/apps/extension-host --frozen-lockfile", "plugin-host:build": "bun run --cwd src/apps/extension-host build", "plugin-host:prepare": "pnpm run plugin-host:install && pnpm run plugin-host:build", - "plugin-host:test": "bun test --cwd src/apps/extension-host", + "plugin-host:test": "bun run --cwd src/apps/extension-host test", + "server:dev": "pnpm run plugin-host:prepare && cargo run --manifest-path src/apps/server/Cargo.toml", + "server:build": "pnpm run plugin-host:prepare && node scripts/server-build.mjs", "frontend:build-all": "node scripts/frontend-build-all.mjs", "preview": "pnpm --dir src/web-ui preview", "desktop:dev": "node scripts/dev.cjs desktop", diff --git a/scripts/check-build-prereqs.mjs b/scripts/check-build-prereqs.mjs index d4a4a0cc0e..9c66372e0c 100644 --- a/scripts/check-build-prereqs.mjs +++ b/scripts/check-build-prereqs.mjs @@ -10,8 +10,8 @@ * - src/mobile-web/dist missing → cargo check -p bitfun-desktop and * cargo check --workspace fail with "resource path '../../mobile-web/dist' * doesn't exist" in the bitfun-desktop build script - * - OpenCode extension Host dist missing → CLI builds cannot bundle the - * Bun plugin Host resources + * - OpenCode extension Host dist missing → Desktop, CLI, and app-server + * builds cannot bundle or launch the Bun plugin Host resources * - sherpa-onnx prebuilt libs missing → sherpa-onnx-sys build script attempts * a network download from GitHub that fails on poor connectivity * @@ -59,7 +59,7 @@ function runChecks(rootDir) { }); } - // --- Check 3: OpenCode extension Host dist (CLI bundled resource) --- + // --- Check 3: OpenCode extension Host dist (product runtime resource) --- const pluginHostDist = join( rootDir, 'src', @@ -72,7 +72,7 @@ function runChecks(rootDir) { errors.push({ name: 'OpenCode extension Host dist', message: - 'src/apps/extension-host/dist is missing the Bun Host entry. CLI builds bundle this directory as the plugin Host resource.', + 'src/apps/extension-host/dist is missing the Bun Host entry. Desktop, CLI, and app-server builds require this plugin Host runtime resource.', fix: ['pnpm', 'run', 'plugin-host:prepare'], }); } diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index c7b74ccd78..cfb408db48 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -353,6 +353,7 @@ test('portable contract crates expose only capability-local feature slices', asy 'default', 'agent-api', 'git-port', + 'hook-function-runtime', 'permission', 'plugin-runtime', 'product-search', @@ -367,6 +368,7 @@ test('portable contract crates expose only capability-local feature slices', asy ]), ); assert.deepEqual(runtimePortFeatures['agent-api'], ['dep:bitfun-core-types']); + assert.deepEqual(runtimePortFeatures['hook-function-runtime'], []); assert.deepEqual(runtimePortFeatures['plugin-runtime'], []); assert.deepEqual(runtimePortFeatures['product-search'], ['dep:bitfun-product-domains']); assert.deepEqual(runtimePortFeatures['script-tool-runtime'], []); @@ -571,6 +573,7 @@ const RUNTIME_PORT_FEATURE_PROFILES = { default: [], 'agent-api': ['dep:bitfun-core-types'], 'git-port': [], + 'hook-function-runtime': [], permission: ['dep:bitfun-product-domains'], 'plugin-runtime': [], 'product-search': ['dep:bitfun-product-domains'], @@ -813,6 +816,11 @@ test('contract and AI adapter tests keep reviewed feature and failure-domain top path: 'tests/git_port_contracts.rs', requiredFeatures: ['git-port'], }, + { + name: 'hook_function_runtime_contracts', + path: 'tests/hook_function_runtime_contracts.rs', + requiredFeatures: ['hook-function-runtime'], + }, { name: 'script_tool_port_contracts', path: 'tests/script_tool_port_contracts.rs', diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index ffab49fcef..ac631f6990 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -323,6 +323,9 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => const checkCompilation = rustJob.steps.find( (step) => step.name === 'Check compilation', ); + const createTauriResources = rustJob.steps.find( + (step) => step.name === 'Create Tauri resource directories', + ); const saveSherpaCache = rustJob.steps.find( (step) => step.name === 'Save Sherpa native libraries', ); @@ -340,6 +343,16 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => assert.equal(saveSherpaCache?.uses, 'actions/cache/save@v5'); assert.equal(saveSherpaCache?.with?.path, 'target/sherpa-onnx-prebuilt'); assert.equal(saveSherpaCache?.with?.key, sherpaCacheKey); + assert.match( + createTauriResources?.run ?? '', + /src\/apps\/extension-host\/dist\/extension-host\.js/, + 'clean Rust checks must provide the generated extension Host resource path without building the Host', + ); + assert.ok( + rustJob.steps.indexOf(createTauriResources) < + rustJob.steps.indexOf(checkCompilation), + 'Tauri resource placeholders must exist before cargo check', + ); assert.equal( saveSherpaCache?.if, "github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') && steps.sherpa-native-cache.outputs.cache-hit != 'true'", diff --git a/scripts/cli-product.mjs b/scripts/cli-product.mjs index 000e5c9f9b..951821eac7 100644 --- a/scripts/cli-product.mjs +++ b/scripts/cli-product.mjs @@ -17,7 +17,7 @@ export function stagePluginHostResources(destination, sourceDirectory = PLUGIN_H const source = join(sourceDirectory, entry); if (!existsSync(source)) { throw new Error( - `CLI plugin Host resource was not produced: ${source}. Run pnpm run plugin-host:prepare.`, + `Plugin Host resource was not produced: ${source}. Run pnpm run plugin-host:prepare.`, ); } } diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 2096888ac5..06bfac0d97 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -152,6 +152,11 @@ export const runtimePortsIntegrationTestTargets = [ path: 'tests/git_port_contracts.rs', requiredFeatures: ['git-port'], }, + { + name: 'hook_function_runtime_contracts', + path: 'tests/hook_function_runtime_contracts.rs', + requiredFeatures: ['hook-function-runtime'], + }, { name: 'script_tool_port_contracts', path: 'tests/script_tool_port_contracts.rs', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 8f426491f6..d6dea312f3 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -122,7 +122,7 @@ export const optionalDependencyFeatureOwnerRules = [ reason: 'agent-runtime optional dependencies must stay behind the full runtime or native-hook owner slice', dependencies: [ - { depName: 'async-trait', ownerFeatures: ['agent-runtime'] }, + { depName: 'async-trait', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-agent-tools', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-core-types', ownerFeatures: ['agent-runtime'] }, @@ -137,7 +137,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'serde_json', ownerFeatures: ['agent-runtime', 'native-hook-runtime', 'native-hook-settings'] }, { depName: 'serde_yaml', ownerFeatures: ['agent-runtime'] }, { depName: 'sha2', ownerFeatures: ['agent-runtime'] }, - { depName: 'thiserror', ownerFeatures: ['agent-runtime'] }, + { depName: 'thiserror', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'tokio', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'tokio-util', ownerFeatures: ['agent-runtime'] }, { depName: 'uuid', ownerFeatures: ['agent-runtime'] }, @@ -379,6 +379,7 @@ export const capabilityContractDependencyRules = [ default: [], 'agent-api': ['dep:bitfun-core-types'], 'git-port': [], + 'hook-function-runtime': [], permission: ['dep:bitfun-product-domains'], 'plugin-runtime': [], 'product-search': ['dep:bitfun-product-domains'], @@ -435,6 +436,7 @@ export const capabilityContractDependencyRules = [ capabilityForwarder('agent-runtime', 'terminal-port'), capabilityForwarder('agent-runtime', 'tool-runtime-handles'), capabilityForwarder('agent-runtime', 'workspace-ports'), + capabilityForwarder('opencode-plugin-host', 'hook-function-runtime'), capabilityForwarder('plugin-runtime', 'plugin-runtime'), capabilityForwarder('product-search', 'product-search'), capabilityForwarder('script-tool-runtime', 'script-tool-runtime'), @@ -450,6 +452,9 @@ export const capabilityContractDependencyRules = [ capabilityEdge(['plugin-runtime']), capabilityEdge(['script-tool-runtime'], { kind: 'dev' }), ])], + ['bitfun-opencode-plugin-host', capabilityConsumer([ + capabilityEdge(['hook-function-runtime']), + ])], ['bitfun-dsh-adapter', capabilityConsumer([ capabilityEdge(['plugin-runtime']), ])], @@ -574,10 +579,12 @@ export const capabilityContractDependencyRules = [ 'native-hook-settings': ['dep:regex', 'dep:serde_json'], 'native-hook-runtime': [ 'native-hook-settings', + 'dep:async-trait', 'dep:log', 'dep:serde', 'dep:serde_json', 'dep:tokio', + 'dep:thiserror', 'tokio/io-util', 'tokio/macros', 'tokio/process', @@ -1017,6 +1024,7 @@ export const coreClosedFeatureProfileRules = [ 'remote-connect', 'git', 'dep:bitfun-opencode-plugin-host', + 'bitfun-runtime-ports/hook-function-runtime', ], allowedTransitiveFeatureRefs: [ 'agent-runtime', @@ -1043,7 +1051,7 @@ export const coreClosedFeatureProfileRules = [ ], exact: true, reason: - 'the managed OpenCode Host must keep its product-shaped route dependencies separate from the portable plugin runtime client boundary without restoring the retired LSP runtime', + 'the managed OpenCode Host must compose one typed Hook/function execution port without restoring the retired LSP runtime or exposing Host wire DTOs', }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 21882de3c1..6007b5ad51 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4060,6 +4060,37 @@ export const rustWebUiSourceBoundaryRule = { export const forbiddenContentUnderRules = [ rustWebUiSourceBoundaryRule, + { + path: 'src/crates/assembly/core/src', + reason: + 'OpenCode backend wire parsing, raw RPC handlers, and wire errors belong to the opencode-plugin-host adapter', + patterns: [ + { + regex: + /\b(?:BackendHttp(?:Request|Response)|Stream(?:Read|Cancel)Params|RpcHandlerError|RawDiagnostic(?:PublishParams)?)\b/, + message: 'core must not consume OpenCode backend wire DTOs or RPC errors', + }, + { + regex: /["']backend\.[a-z0-9_.-]+["']/i, + message: 'core must not own raw OpenCode backend RPC method names', + }, + { + regex: /register_handler\s*\(\s*["']backend\./, + message: 'core must not register raw OpenCode backend RPC handlers', + }, + ], + }, + { + path: 'src/crates/adapters/opencode-plugin-host/src', + reason: + 'raw OpenCode RPC handler registration is an adapter-internal implementation detail', + patterns: [ + { + regex: /\bpub\s+async\s+fn\s+register_handler\b/, + message: 'the raw JSON-RPC handler registration API must not be public', + }, + ], + }, { path: 'src/crates/adapters/agent-runtime-ipc/src', reason: 'agent-runtime-ipc transport is restricted to Named Pipe and Unix Domain Socket', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 1723204045..2b5f785545 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -265,6 +265,18 @@ export const opencodeAdapterPublicApiEntries = [ 'load_opencode_package_adapter', 'bitfun-core managed plugin composition root and DefaultPluginRuntimeClient integration tests', ), + opencodeAdapterEntry( + 'load_opencode_config_snapshot', + 'bitfun-core live Plugin Host composition root and OpenCode config snapshot contract tests', + ), + opencodeAdapterEntry( + 'OpenCodeConfigSnapshot', + 'bitfun-core live Plugin Host config input and OpenCode config snapshot contract tests', + ), + opencodeAdapterEntry( + 'OpenCodeConfigSnapshotError', + 'bitfun-core live Plugin Host config validation and OpenCode config snapshot contract tests', + ), opencodeAdapterEntry( 'OpenCodeCommandProvider', 'bitfun-core external source composition root and OpenCode command adapter tests', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 3e9ea5f4bf..7b239f3b05 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1760,54 +1760,24 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', - reason: - 'agent-runtime must own portable hook registry and post-call routing decisions while concrete hook execution stays in the owning runtime', + path: 'src/crates/execution/agent-runtime/src/native_hooks/kind.rs', + reason: 'agent-runtime must own portable hook kind contracts', patterns: [ - { - regex: /\bpub enum RuntimeHookKind\b/, - message: 'missing runtime hook kind contract', - }, - { - regex: /\bpub enum RuntimeHookErrorPolicy\b/, - message: 'missing runtime hook error policy contract', - }, - { - regex: /\bpub struct RuntimeHookPlan\b/, - message: 'missing runtime hook plan contract', - }, - { - regex: /\bpub struct RuntimeHookRegistry\b/, - message: 'missing runtime hook registry contract', - }, - { - regex: /\btimeout_millis\b/, - message: 'missing runtime hook timeout contract', - }, - { - regex: /\bDuplicateHookId\b/, - message: 'missing runtime hook duplicate-id guard', - }, - { - regex: /\bEmptyHookId\b/, - message: 'missing runtime hook empty-id guard', - }, - { - regex: /\bInvalidTimeoutMillis\b/, - message: 'missing runtime hook non-zero-timeout guard', - }, - { - regex: /\bpub const fn successful_tool_post_call_hooks\b/, - message: 'missing successful tool post-call hook routing decision', - }, - { - regex: /\bpub trait SuccessfulToolPostCallHookExecutor\b/, - message: 'missing successful tool post-call hook executor contract', - }, - { - regex: /\bpub fn run_successful_tool_post_call_hooks\b/, - message: 'missing successful tool post-call hook executor runner', - }, + { regex: /\bpub enum RuntimeHookKind\b/, message: 'missing runtime hook kind contract' }, + { regex: /\bSuccessfulToolPostCall\b/, message: 'missing successful tool post-call hook kind' }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/native_hooks/registry.rs', + reason: 'agent-runtime must own portable hook registry and validation contracts', + patterns: [ + { regex: /\bpub enum RuntimeHookErrorPolicy\b/, message: 'missing runtime hook error policy contract' }, + { regex: /\bpub struct RuntimeHookPlan\b/, message: 'missing runtime hook plan contract' }, + { regex: /\bpub struct RuntimeHookRegistry\b/, message: 'missing runtime hook registry contract' }, + { regex: /\btimeout_millis\b/, message: 'missing runtime hook timeout contract' }, + { regex: /\bDuplicateHookId\b/, message: 'missing runtime hook duplicate-id guard' }, + { regex: /\bEmptyHookId\b/, message: 'missing runtime hook empty-id guard' }, + { regex: /\bInvalidTimeoutMillis\b/, message: 'missing runtime hook non-zero-timeout guard' }, ], }, { @@ -1816,11 +1786,11 @@ export const requiredContentRules = [ 'agent-runtime post-call hook owner must keep behavior-equivalence contracts for successful tool-call hook routing', patterns: [ { - regex: /\bsuccessful_tool_call_routes_to_shared_context_measurement_hook\b/, + regex: /\bsuccessful_tool_call_uses_stable_builtin_registration_id\b/, message: 'missing successful tool post-call hook routing regression', }, { - regex: /\bruntime_hook_registry_preserves_order_timeout_and_error_policy\b/, + regex: /\bruntime_hook_registry_preserves_source_order_timeout_and_error_policy\b/, message: 'missing runtime hook order/timeout/error-policy regression', }, { @@ -2702,14 +2672,7 @@ export const requiredContentRules = [ reason: 'core post-call hooks must delegate portable hook routing to agent-runtime while retaining concrete hook execution', patterns: [ - { - regex: /\brun_successful_tool_post_call_hooks\b/, - message: 'missing post-call hook executor runner delegation', - }, - { - regex: /\bSuccessfulToolPostCallHookExecutor\b/, - message: 'missing post-call hook executor implementation', - }, + { regex: /\bdispatch_successful_tool_post_call\b/, message: 'missing post-call hook dispatch delegation' }, ], }, { @@ -5283,6 +5246,8 @@ export const requiredContentRules = [ { regex: /#\[cfg\(feature = "agent-api"\)\]\r?\npub use agent_api::\*;/, message: 'agent-api must gate its public exports' }, { regex: /#\[cfg\(feature = "plugin-runtime"\)\]\r?\nmod plugin;/, message: 'plugin-runtime must gate its source module' }, { regex: /#\[cfg\(feature = "plugin-runtime"\)\]\r?\npub use plugin::\{/, message: 'plugin-runtime must gate its public exports' }, + { regex: /#\[cfg\(feature = "hook-function-runtime"\)\]\r?\nmod hook_function;/, message: 'hook-function-runtime must gate its source module' }, + { regex: /#\[cfg\(feature = "hook-function-runtime"\)\]\r?\npub use hook_function::\*;/, message: 'hook-function-runtime must gate its public exports' }, { regex: /#\[cfg\(feature = "product-search"\)\]\r?\nmod product_search;/, message: 'product-search must gate its source module' }, { regex: /#\[cfg\(feature = "product-search"\)\]\r?\npub use product_search::ProductSearchPort;/, message: 'product-search must gate its public exports' }, { regex: /#\[cfg\(feature = "script-tool-runtime"\)\]\r?\nmod script_tool;/, message: 'script-tool-runtime must gate its source module' }, @@ -8434,23 +8399,31 @@ export const requiredContentRules = [ { path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', reason: - 'web-ui file operation surface must continue owning snapshot-to-local diff fallback until product surface migration is reviewed', + 'web-ui file operation surface must preserve snapshot-independent inline diffs after the design-system migration', patterns: [ { - regex: /\bopenLocalDiff\b/, - message: 'missing local tool diff fallback', + regex: /\bInlineDiffPreview\b/, + message: 'missing snapshot-independent inline diff renderer', + }, + { + regex: /previewVariant === 'completed-diff'/, + message: 'missing completed file-operation diff state', + }, + { + regex: /originalContent=\{oldStringContent\}/, + message: 'missing Edit original-content projection', }, { - regex: /snapshotAPI\.getOperationDiff/, - message: 'missing snapshot operation diff path', + regex: /modifiedContent=\{newStringContent\}/, + message: 'missing Edit modified-content projection', }, { - regex: /Snapshot diff unavailable/, - message: 'missing snapshot-unavailable fallback diagnostic', + regex: /originalContent=""/, + message: 'missing Write empty-baseline projection', }, { - regex: /\blocalDiffContent\b/, - message: 'missing local diff content fallback state', + regex: /modifiedContent=\{contentPreview\}/, + message: 'missing Write content projection', }, ], }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 8c9b864d00..283d675637 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1031,7 +1031,8 @@ export function runManifestParserSelfTest({ throw new Error('core workspace manager boundary rule must forbid contract: RelatedPath'); } const coreSubagentRuntimeOwnerPathRule = forbiddenContentUnderRules.find( - (rule) => rule.path === 'src/crates/assembly/core/src', + (rule) => rule.path === 'src/crates/assembly/core/src' + && rule.reason.includes('owner path for portable subagent contracts'), ); if (!coreSubagentRuntimeOwnerPathRule) { throw new Error('missing core subagent runtime owner-path boundary rule'); @@ -1603,7 +1604,7 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,OpenCodeWorkspaceReferenceProvider,OpenCodeWorkspaceReferenceProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions' + 'load_opencode_package_adapter,load_opencode_config_snapshot,OpenCodeConfigSnapshot,OpenCodeConfigSnapshotError,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,OpenCodeWorkspaceReferenceProvider,OpenCodeWorkspaceReferenceProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions' ) { throw new Error( 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, workspace Reference, and user Instruction providers', @@ -1990,6 +1991,46 @@ export function runManifestParserSelfTest({ ) { throw new Error('OpenCode adapter source guard must allow only the reviewed core composition file'); } + const opencodeHostWireRule = forbiddenContentUnderRules.find((rule) => + rule.reason.includes('OpenCode backend wire parsing'), + ); + if (opencodeHostWireRule?.path !== 'src/crates/assembly/core/src') { + throw new Error('OpenCode Host wire guard must scan the complete core source tree'); + } + const opencodeHostWireRegexes = opencodeHostWireRule.patterns.map((pattern) => pattern.regex); + for (const sample of [ + 'let request: BackendHttpRequest = decode(params)?;', + 'let params: StreamCancelParams = decode(params)?;', + 'let error = RpcHandlerError::new(-32602, message);', + 'client.register_handler("backend.http.request", handler);', + 'const METHOD: &str = "backend.stream.read";', + 'const METHOD: &str = "backend.stream.cancel";', + 'const METHOD: &str = "backend.diagnostic.publish";', + ]) { + if (!opencodeHostWireRegexes.some((regex) => regex.test(sample))) { + throw new Error(`OpenCode Host wire guard missed fixture: ${sample}`); + } + } + if ( + [ + 'impl OpenCodeBackendHandler for CoreOpenCodeBackend {}', + 'let request: BackendRouteRequest = request;', + 'let event: BackendDiagnosticEvent = event;', + ].some((sample) => opencodeHostWireRegexes.some((regex) => regex.test(sample))) + ) { + throw new Error('OpenCode Host wire guard must allow the typed adapter callback'); + } + const rawHandlerVisibilityRule = forbiddenContentUnderRules.find((rule) => + rule.reason.includes('raw OpenCode RPC handler registration'), + ); + const rawHandlerVisibilityRegex = rawHandlerVisibilityRule?.patterns?.[0]?.regex; + if ( + rawHandlerVisibilityRule?.path !== 'src/crates/adapters/opencode-plugin-host/src' || + !rawHandlerVisibilityRegex?.test('pub async fn register_handler() {}') || + rawHandlerVisibilityRegex.test('pub(crate) async fn register_handler() {}') + ) { + throw new Error('OpenCode raw handler visibility guard must keep registration crate-private'); + } const runtimeServicesRule = lightweightBoundaryRules.find( (rule) => rule.crateName === 'runtime-services', ); @@ -2818,24 +2859,24 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', + path: 'src/crates/execution/agent-runtime/src/native_hooks/kind.rs', + contracts: ['RuntimeHookKind', 'SuccessfulToolPostCall'], + }, + { + path: 'src/crates/execution/agent-runtime/src/native_hooks/registry.rs', contracts: [ - 'RuntimeHookKind', 'RuntimeHookErrorPolicy', 'RuntimeHookPlan', 'RuntimeHookRegistry', 'EmptyHookId', 'InvalidTimeoutMillis', - 'successful_tool_post_call_hooks', - 'SuccessfulToolPostCallHookExecutor', - 'run_successful_tool_post_call_hooks', ], }, { path: 'src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs', contracts: [ - 'successful_tool_call_routes_to_shared_context_measurement_hook', - 'runtime_hook_registry_preserves_order_timeout_and_error_policy', + 'successful_tool_call_uses_stable_builtin_registration_id', + 'runtime_hook_registry_preserves_source_order_timeout_and_error_policy', 'runtime_hook_registry_rejects_duplicate_ids', 'runtime_hook_registry_rejects_unstable_ids_and_zero_timeouts', ], @@ -4530,7 +4571,14 @@ export function runManifestParserSelfTest({ }, { path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', - contracts: ['openLocalDiff', 'snapshotAPI\\.getOperationDiff', 'Snapshot diff unavailable', 'localDiffContent'], + contracts: [ + 'InlineDiffPreview', + "previewVariant === 'completed-diff'", + 'originalContent=\\{oldStringContent\\}', + 'modifiedContent=\\{newStringContent\\}', + 'originalContent=""', + 'modifiedContent=\\{contentPreview\\}', + ], }, { path: 'src/web-ui/src/main.tsx', diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index b6df17e251..5e66563483 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -47,6 +47,7 @@ async function main() { console.log(`[release] channel=${releaseChannel.channel}`); const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); + preparePluginHost(); const flashgrepBinary = prepareMacOSFlashgrepForSigning( ensureFlashgrepBinary(), desktopDir, @@ -101,6 +102,21 @@ async function main() { process.exit(r.status ?? 1); } +function preparePluginHost() { + const result = spawnSync('pnpm', ['run', 'plugin-host:prepare'], { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + shell: true, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`OpenCode extension Host preparation failed with exit code ${result.status}`); + } +} + function runTauriBuild(tauriBin, args, desktopDir) { return spawnSync(tauriBin, args, { cwd: desktopDir, diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index b942903efc..a89c454637 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -33,6 +33,21 @@ test('Desktop DMG uses the branded installer layout', () => { }); }); +test('Desktop builds prepare and bundle the OpenCode extension Host', () => { + const source = readFileSync(join(ROOT, 'scripts', 'desktop-tauri-build.mjs'), 'utf8'); + assert.match(source, /preparePluginHost\(\)/); + + for (const name of ['tauri.conf.json', 'tauri.dev.conf.json']) { + const config = JSON.parse( + readFileSync(join(ROOT, 'src', 'apps', 'desktop', name), 'utf8') + ); + assert.equal( + config.bundle.resources['../extension-host/dist/extension-host.js'], + 'resources/ext-host/extension-host.js' + ); + } +}); + test('macOS release signing covers the bundled flashgrep executable', () => { const fixture = join(tmpdir(), `bitfun-flashgrep-signing-${process.pid}-${Date.now()}`); const desktopDir = join(fixture, 'src', 'apps', 'desktop'); diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 34f55d6471..d349472713 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -657,7 +657,7 @@ async function main() { let currentStep = 1; // Step 1: Run all independent preparation tasks in parallel. - // copy-monaco / generate-version / mobile-web / flashgrep have no + // copy-monaco / generate-version / mobile-web / flashgrep / plugin-host have no // dependencies on each other; each task's output is line-prefixed so the // interleaved logs stay attributable. The DeepSeek bridge is not prepared // here: it is not a compile-time Tauri resource. Official desktop:build @@ -666,7 +666,7 @@ async function main() { currentStep++, totalSteps, desktopMode - ? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep)' + ? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep, plugin-host)' : 'Prepare resources (parallel: monaco, version)' ); @@ -687,6 +687,11 @@ async function main() { ]; if (desktopMode) { + prepTasks.push({ + name: 'Prepare OpenCode extension Host', + hint: 'Hint: install Bun, then run `pnpm run plugin-host:prepare`', + promise: runCommandPrefixed('plugin-host', 'pnpm', ['run', 'plugin-host:prepare']), + }); prepTasks.push({ name: 'Build mobile-web', promise: runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']), diff --git a/scripts/server-build.mjs b/scripts/server-build.mjs new file mode 100644 index 0000000000..119f4db1bb --- /dev/null +++ b/scripts/server-build.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { stagePluginHostResources } from './cli-product.mjs'; + +const ROOT = path.resolve(import.meta.dirname, '..'); + +export function serverBuildPlan(root = ROOT, environment = process.env, platform = process.platform) { + const targetDirectory = environment.CARGO_TARGET_DIR + ? path.resolve(root, environment.CARGO_TARGET_DIR) + : path.join(root, 'target'); + const target = environment.CARGO_BUILD_TARGET?.trim(); + const releaseDirectory = path.join(targetDirectory, ...(target ? [target] : []), 'release'); + const windowsTarget = target ? target.split('-').includes('windows') : platform === 'win32'; + + return { + cargoArgs: [ + 'build', + '--release', + '--manifest-path', + path.join(root, 'src', 'apps', 'server', 'Cargo.toml'), + ], + binaryPath: path.join(releaseDirectory, `bitfun-server${windowsTarget ? '.exe' : ''}`), + pluginHostDestination: path.join(releaseDirectory, 'resources', 'ext-host'), + }; +} + +function run() { + const plan = serverBuildPlan(); + const result = spawnSync('cargo', plan.cargoArgs, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + }); + if (result.error || result.status !== 0) { + throw result.error ?? new Error(`Server cargo build exited with status ${result.status}`); + } + if (!existsSync(plan.binaryPath)) { + throw new Error(`Server binary was not produced: ${plan.binaryPath}`); + } + stagePluginHostResources(plan.pluginHostDestination); + console.log(`[server] staged plugin Host: ${plan.pluginHostDestination}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + run(); + } catch (error) { + console.error(error?.stack || error); + process.exitCode = 1; + } +} diff --git a/scripts/server-build.test.mjs b/scripts/server-build.test.mjs new file mode 100644 index 0000000000..7119bc051e --- /dev/null +++ b/scripts/server-build.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { serverBuildPlan } from './server-build.mjs'; + +test('server release builds stage the plugin Host beside the binary', () => { + const root = path.resolve('D:/workspace/bitfun-fixture'); + const plan = serverBuildPlan(root, {}, 'win32'); + + assert.deepEqual(plan.cargoArgs, [ + 'build', + '--release', + '--manifest-path', + path.join(root, 'src', 'apps', 'server', 'Cargo.toml'), + ]); + assert.equal(plan.binaryPath, path.join(root, 'target', 'release', 'bitfun-server.exe')); + assert.equal( + plan.pluginHostDestination, + path.join(root, 'target', 'release', 'resources', 'ext-host'), + ); +}); + +test('server release builds respect a configured Cargo target directory', () => { + const root = path.resolve('D:/workspace/bitfun-fixture'); + const plan = serverBuildPlan(root, { CARGO_TARGET_DIR: 'build-output' }, 'linux'); + + assert.equal(plan.binaryPath, path.join(root, 'build-output', 'release', 'bitfun-server')); + assert.equal( + plan.pluginHostDestination, + path.join(root, 'build-output', 'release', 'resources', 'ext-host'), + ); +}); diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index ce75ca2965..b44160b19d 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -112,8 +112,11 @@ restrictions remain enforced. closure with `product-full` or a CLI-named umbrella; add a Core feature only when a production CLI path consumes that owner. - CLI consumes typed external-source summaries and actions. It does not parse - source files, import executable modules, start plugin workers, duplicate - approval state, or treat static discovery as runtime availability. + source files, import executable modules, implement or supervise plugin workers, + duplicate approval state, or treat static discovery as runtime availability. + A local product host may request Core-owned configured Plugin Host startup and + instance activation; the lifecycle, worker, and protocol owners remain below + the CLI surface. - ACP agents, configuration import, executable plugins, Hooks, and Peer Device hosting have separate trust and lifecycle state. Do not infer one from another. diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index b619d60112..82798680e4 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -367,6 +367,7 @@ enum CliAgentRuntimeBackend { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliAgentMode { pub(crate) id: String, + pub(crate) route_key: String, pub(crate) description: String, pub(crate) model_id: Option, pub(crate) is_external: bool, @@ -444,24 +445,43 @@ impl CliAgentRuntimeClient { /// consults the controller process's local registry. pub(crate) async fn available_agent_modes(&self) -> Result> { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .list_agent_modes(AgentModeCatalogQuery { - workspace_root: Some(self.workspace_path_string()), - include_external: true, - }) - .await - .map(|modes| { - modes - .into_iter() - .map(|mode| CliAgentMode { - id: mode.id, - description: mode.description, - model_id: mode.model_id, - is_external: mode.is_external, - }) - .collect() - }) - .map_err(|error| anyhow::anyhow!(error.into_message())), + CliAgentRuntimeBackend::Embedded(runtime) => { + let binding = self.current_workspace_binding(); + if let Err(error) = self.ensure_embedded_plugin_workspace_ready(&binding).await { + tracing::warn!( + "Configured plugin activation failed while loading agent modes; continuing with native agents: {}", + error + ); + } + let workspace = PathBuf::from(&binding.workspace_path); + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( + &workspace, + )) + .await + { + tracing::warn!("Failed to initialize external agent sources: {error}"); + } + runtime + .list_agent_modes(AgentModeCatalogQuery { + workspace_root: Some(workspace.to_string_lossy().to_string()), + include_external: true, + }) + .await + .map(|modes| { + modes + .into_iter() + .map(|mode| CliAgentMode { + id: mode.id, + route_key: mode.route_key, + description: mode.description, + model_id: mode.model_id, + is_external: mode.is_external, + }) + .collect() + }) + .map_err(|error| anyhow::anyhow!(error.into_message())) + } CliAgentRuntimeBackend::Shared(client) => { let session_id = self.session_id.lock().await.clone(); match client @@ -472,6 +492,7 @@ impl CliAgentRuntimeClient { .into_iter() .map(|mode| CliAgentMode { id: mode.id, + route_key: mode.route_key, description: mode.description, model_id: mode.model_id, is_external: mode.is_external, @@ -602,6 +623,13 @@ impl CliAgentRuntimeClient { .apply_binding(binding); } + fn current_workspace_binding(&self) -> AgentSessionWorkspaceBinding { + self.workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .binding() + } + pub(crate) fn remote_workspace_scope(&self) -> (Option, Option) { let paths = self .workspace_paths @@ -1065,10 +1093,21 @@ impl CliAgentRuntimeClient { &self, session_id: &str, mode_id: &str, + ) -> std::result::Result<(), SessionOperationError> { + self.update_session_mode_with_route(session_id, mode_id, None) + .await + } + + pub(crate) async fn update_session_mode_with_route( + &self, + session_id: &str, + mode_id: &str, + route_key: Option<&str>, ) -> std::result::Result<(), SessionOperationError> { let request = AgentSessionModeUpdateRequest { session_id: session_id.to_string(), mode_id: mode_id.to_string(), + agent_route_key: route_key.map(str::to_string), }; match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => runtime @@ -1351,6 +1390,7 @@ impl CliAgentRuntimeClient { AgentSessionCreateRequest { session_name, agent_type: effective_agent_type, + agent_route_key: None, workspace_path: Some(workspace.to_string_lossy().to_string()), project_workspace_path: Some(project_workspace.to_string_lossy().to_string()), execution_target: self.execution_target(), @@ -1424,6 +1464,7 @@ impl CliAgentRuntimeClient { AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), + agent_route_key: None, workspace_path: Some(workspace_path), project_workspace_path: Some(project_workspace_path), execution_target: self.execution_target(), @@ -1468,6 +1509,7 @@ impl CliAgentRuntimeClient { let request = AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), + agent_route_key: None, workspace_path: Some(self.workspace_path_string()), project_workspace_path: None, execution_target: None, @@ -1915,6 +1957,7 @@ impl CliAgentRuntimeClient { let request = AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), + agent_route_key: None, workspace_path: Some(project_workspace_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), execution_target: Some(SessionExecutionTarget::local(project_workspace_path)), @@ -2774,6 +2817,7 @@ mod dual_backend_behavior_tests { use bitfun_runtime_ports::{ ClockPort, PermissionAuditRecord, PermissionAuditStorePort, PermissionReplyStorePort, RuntimeServiceCapability, RuntimeServicePort, SessionExecutionTarget, + SessionExecutionTargetKind, WorktreeLifecycle, }; use crate::shared_runtime::SharedRuntimeHandler; @@ -2853,12 +2897,14 @@ mod dual_backend_behavior_tests { vec![ AgentModeCatalogEntry { id: "agentic".to_string(), + route_key: "agentic".to_string(), description: "Primary workspace agent".to_string(), model_id: Some("primary-model".to_string()), is_external: false, }, AgentModeCatalogEntry { id: "workspace-plan".to_string(), + route_key: "external::workspace-plan".to_string(), description: "Workspace plan agent".to_string(), model_id: Some("plan-model".to_string()), is_external: true, @@ -3257,6 +3303,34 @@ mod dual_backend_behavior_tests { } } + #[test] + fn embedded_client_preserves_managed_workspace_binding() { + let root = tempfile::tempdir().expect("workspace root"); + let workspace = dunce::canonicalize(root.path()).expect("canonical workspace"); + let fixture = Fixture::new(&workspace); + let client = fixture.embedded_client(); + let binding = AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: workspace.join("managed-worktree").display().to_string(), + project_workspace_path: Some(workspace.display().to_string()), + execution_target: Some(SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("worktree-1".to_string()), + root_path: workspace.join("managed-worktree").display().to_string(), + base_ref: Some("main".to_string()), + base_commit: Some("123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }), + remote_connection_id: None, + remote_ssh_host: None, + }; + + client.set_workspace_binding(&binding); + + assert_eq!(client.current_workspace_binding(), binding); + } + async fn shared_backend( fixture: &Fixture, ) -> ( @@ -3611,6 +3685,7 @@ mod dual_backend_behavior_tests { let create_request = AgentSessionCreateRequest { session_name: "remote-unsupported-session".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(fixture.workspace.to_string_lossy().into_owned()), project_workspace_path: Some(fixture.workspace.to_string_lossy().into_owned()), execution_target: Some(SessionExecutionTarget::local( diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index a294fcefd3..deaf7134ea 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -181,6 +181,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { AgentSessionCreateRequest { session_name: job.title.clone(), agent_type: job.request.agent_type.clone(), + agent_route_key: None, workspace_path: Some(workspace_path.clone()), project_workspace_path: Some(workspace_path.clone()), execution_target: Some(SessionExecutionTarget::local(workspace_path.clone())), diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index d63e55ee02..f97c4abd5e 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -52,7 +52,7 @@ use modes::chat::ChatMode; use modes::exec::{ExecApprovalMode, ExecOutputFormat}; pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = - bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled; + bitfun_core::plugin_host::PluginHostLaunchPolicy::Enabled; // ======================== Global MCP Service ======================== @@ -647,6 +647,8 @@ enum ExternalConfigAction { Status, /// Enable or disable external compatibility SetEnabled { + /// Whether external compatibility is enabled + #[arg(action = clap::ArgAction::Set, value_parser = clap::value_parser!(bool))] enabled: bool, #[arg(long, value_enum, default_value = "project")] scope: ExternalPolicyScopeArg, @@ -870,7 +872,16 @@ async fn initialize_core_services_for_deployment( bootstrap_profile, BootstrapProfile::Interactive | BootstrapProfile::Execution ) { - plugin_host_activation::ensure_configured_plugin_execution_supported().await?; + if let Err(error) = + plugin_host_activation::ensure_configured_plugin_execution_supported().await + { + bitfun_core::plugin_host::report_configured_plugin_activation_failure( + "CLI startup configuration", + Some(workspace_root), + error, + ) + .await; + } } if bootstrap_profile.starts_plugin_host() { match bitfun_core::plugin_host::initialize_configured_plugin_host_with_log_file( @@ -881,7 +892,14 @@ async fn initialize_core_services_for_deployment( { Ok(bitfun_core::plugin_host::PluginHostStartup::Disabled) => {} Ok(status) => tracing::info!("Plugin host initialization completed: {:?}", status), - Err(error) => tracing::error!("Failed to initialize configured plugin host: {error}"), + Err(error) => { + bitfun_core::plugin_host::report_configured_plugin_activation_failure( + "CLI startup", + Some(workspace_root), + error, + ) + .await; + } } } let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() @@ -1841,6 +1859,28 @@ mod external_config_command_tests { }) )); + let enabled = Cli::try_parse_from([ + "bitfun", + "config", + "external", + "set-enabled", + "true", + "--scope", + "global", + ]) + .expect("parse external enabled value"); + assert!(matches!( + enabled.command, + Some(Commands::Config { + action: ConfigAction::External { + action: ExternalConfigAction::SetEnabled { + enabled: true, + scope: ExternalPolicyScopeArg::Global, + } + } + }) + )); + let mode = Cli::try_parse_from([ "bitfun", "config", @@ -1909,8 +1949,8 @@ mod bootstrap_profile_tests { #[test] fn profiles_start_only_their_requested_background_services() { let cases = [ - (BootstrapProfile::Interactive, true, true, false), - (BootstrapProfile::Execution, false, true, false), + (BootstrapProfile::Interactive, true, true, true), + (BootstrapProfile::Execution, false, true, true), (BootstrapProfile::Management, false, false, false), ]; diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 9384b0f0c5..532e4d659c 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -455,6 +455,7 @@ impl ChatMode { let selected = AgentItem { id: next.id.clone(), + route_key: (!next.route_key.is_empty()).then(|| next.route_key.clone()), description: next.description.clone(), }; self.apply_agent_selection(&selected, chat_view, chat_state, rt_handle); @@ -624,6 +625,7 @@ impl ChatMode { .into_iter() .map(|m| AgentItem { id: m.id, + route_key: (!m.route_key.is_empty()).then_some(m.route_key), description: m.description, }) .collect(); @@ -689,13 +691,18 @@ impl ChatMode { let session_id = chat_state.core_session_id.clone(); let mode_id = selected.id.clone(); + let route_key = selected.route_key.clone(); let task_mode_id = mode_id.clone(); let agent = self.agent.clone(); chat_view.set_status(Some(format!("Switching agent mode to {mode_id}..."))); let task_session_id = session_id.clone(); let handle = rt_handle.spawn(async move { agent - .update_session_mode(&task_session_id, &task_mode_id) + .update_session_mode_with_route( + &task_session_id, + &task_mode_id, + route_key.as_deref(), + ) .await }); self.pending_session_operation = Some(PendingSessionOperation { diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 837449ac2e..fd18f3340c 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -84,7 +84,7 @@ mod tests { .expect("agent selection boundary") .0; - assert!(selection.contains(".update_session_mode(&task_session_id, &task_mode_id)")); + assert!(selection.contains(".update_session_mode_with_route(")); assert!(!selection.contains("selected.id == self.agent_type")); } diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index d939cfa76e..1224758a94 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -467,6 +467,7 @@ pub(crate) async fn create_session(state: &PeerHostState, args: &Value) -> Resul let create_request = AgentSessionCreateRequest { session_name, agent_type, + agent_route_key: None, workspace_path: Some(workspace_path), project_workspace_path: None, execution_target: None, @@ -651,6 +652,7 @@ pub(crate) async fn update_session_mode( .update_session_mode(AgentSessionModeUpdateRequest { session_id, mode_id, + agent_route_key: None, }) .await .map_err(|error| format!("Failed to update session mode: {}", error.into_message()))?; @@ -691,6 +693,21 @@ pub(crate) async fn get_available_modes( .await .map_err(|error| error.encode())?; if let Some(workspace) = workspace.as_deref() { + if let Err(error) = bitfun_core::plugin_host::ensure_configured_plugin_instance( + crate::PLUGIN_HOST_LAUNCH_POLICY, + workspace.to_path_buf(), + workspace.to_path_buf(), + optional_string(request, "workspaceId"), + ) + .await + { + bitfun_core::plugin_host::report_configured_plugin_activation_failure( + "CLI Peer mode catalog", + Some(workspace), + error, + ) + .await; + } if let Err(error) = bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( workspace, @@ -941,6 +958,26 @@ mod tests { assert!(rollback.contains("ensure_session_workspace_runtime_ownership")); } + #[test] + fn peer_mode_catalog_activates_plugins_before_reading_the_registry() { + let source = include_str!("session.rs").replace("\r\n", "\n"); + let command = source + .split_once("pub(crate) async fn get_available_modes(") + .expect("Peer mode catalog") + .1 + .split_once("pub(crate) async fn get_session_stats(") + .expect("Peer mode catalog boundary") + .0; + + let activation = command + .find("ensure_configured_plugin_instance(") + .expect("configured plugin activation"); + let catalog_read = command + .find(".get_modes_info_for_workspace(") + .expect("registry mode catalog read"); + assert!(activation < catalog_read); + } + #[test] fn basic_restore_keeps_peer_host_session_shape() { let value = restored_session_to_json(AgentSessionRestoreResult { diff --git a/src/apps/cli/src/plugin_host_activation.rs b/src/apps/cli/src/plugin_host_activation.rs index d4d13a26f3..cf5f5b6c50 100644 --- a/src/apps/cli/src/plugin_host_activation.rs +++ b/src/apps/cli/src/plugin_host_activation.rs @@ -36,21 +36,29 @@ pub(crate) async fn ensure_configured_plugin_execution_supported() -> BitFunResu let config_service = bitfun_core::service::config::get_global_config_service().await?; let config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await?; - let has_configured_plugins = config.has_configured_plugins(); - if has_configured_plugins - && crate::PLUGIN_HOST_LAUNCH_POLICY - == bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled - { - return Err(bitfun_core::BitFunError::NotImplemented( - "Configured Plugin Host execution is not enabled; plugin definitions were not imported or executed" - .to_string(), - )); - } - Ok(has_configured_plugins) + // A plugin declaration is itself the user's explicit opt-in. External + // integration policy and activation approval are no longer prerequisites + // for starting the configured Plugin Host; they remain independent + // controls for other external-source features. + Ok(config.has_configured_plugins()) } pub(crate) async fn ensure_plugin_workspace_ready( binding: &AgentSessionWorkspaceBinding, +) -> BitFunResult<()> { + if let Err(error) = try_ensure_plugin_workspace_ready(binding).await { + bitfun_core::plugin_host::report_configured_plugin_activation_failure( + "CLI workspace activation", + Some(std::path::Path::new(&binding.workspace_path)), + error, + ) + .await; + } + Ok(()) +} + +async fn try_ensure_plugin_workspace_ready( + binding: &AgentSessionWorkspaceBinding, ) -> BitFunResult<()> { if !ensure_configured_plugin_execution_supported().await? { return Ok(()); @@ -68,7 +76,6 @@ pub(crate) async fn ensure_plugin_workspace_ready( target.directory, target.worktree, target.project_id, - serde_json::Map::new(), ) .await .map(|_| ()) @@ -112,10 +119,10 @@ mod tests { } #[test] - fn cli_does_not_enable_unowned_plugin_execution() { + fn cli_enables_configured_plugin_execution_after_core_authorization() { assert_eq!( crate::PLUGIN_HOST_LAUNCH_POLICY, - bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled + bitfun_core::plugin_host::PluginHostLaunchPolicy::Enabled ); } } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 3c876b9447..f9ac476f14 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -4,17 +4,6 @@ use std::collections::BTreeSet; use std::io::IsTerminal; use std::path::Path; -use bitfun_agent_runtime::sdk::{AgentSessionRestoreRequest, SessionTranscriptRequest}; -use bitfun_core::external_sources::{ - external_source_snapshot, sanitize_external_source_operation_error, - update_external_integration_policy, EcosystemId, ExternalIntegrationAccess, - ExternalIntegrationCapabilityId, ExternalIntegrationMode, ExternalIntegrationPolicyMutation, - ExternalIntegrationPolicyOperation, ExternalIntegrationPolicyScope, - ExternalIntegrationPolicyStatus, ExternalSourceCatalogSnapshot, - ExternalSourceOperationErrorCode, EXTERNAL_CAPABILITY_COMMAND, EXTERNAL_CAPABILITY_MCP, - EXTERNAL_CAPABILITY_SUBAGENT, EXTERNAL_CAPABILITY_TOOL, -}; - use crate::{ chat_state::{transcript_message_preview, transcript_role_label}, config::CliConfig, @@ -26,6 +15,16 @@ use crate::{ ConfigAction, DispatchAction, ExternalAccessArg, ExternalCapabilityArg, ExternalConfigAction, ExternalPolicyModeArg, ExternalPolicyScopeArg, SessionAction, }; +use bitfun_agent_runtime::sdk::{AgentSessionRestoreRequest, SessionTranscriptRequest}; +use bitfun_core::external_sources::{ + external_source_snapshot, sanitize_external_source_operation_error, + update_external_integration_policy, EcosystemId, ExternalIntegrationAccess, + ExternalIntegrationCapabilityId, ExternalIntegrationMode, ExternalIntegrationPolicyMutation, + ExternalIntegrationPolicyOperation, ExternalIntegrationPolicyScope, + ExternalIntegrationPolicyStatus, ExternalSourceCatalogSnapshot, + ExternalSourceOperationErrorCode, EXTERNAL_CAPABILITY_COMMAND, EXTERNAL_CAPABILITY_MCP, + EXTERNAL_CAPABILITY_SUBAGENT, EXTERNAL_CAPABILITY_TOOL, +}; /// Sized for submit/continue requests carrying inline image attachments /// (16 MiB of data URLs) plus headroom for the rest of the payload. diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 54b01811a8..0e019fa11b 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -239,14 +239,34 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { match operation { RuntimeIpcOperation::Health => unreachable!("Health is owned by the IPC server"), RuntimeIpcOperation::ListAgentModes { session_id } => { - let workspace = match session_id { - Some(session_id) => PathBuf::from( - self.session_workspace_binding(&session_id) - .await? - .workspace_path, - ), - None => self.workspace.clone(), + let binding = match session_id { + Some(session_id) => self.session_workspace_binding(&session_id).await?, + None => AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: self.workspace.to_string_lossy().to_string(), + project_workspace_path: Some(self.workspace.to_string_lossy().to_string()), + execution_target: Some( + bitfun_runtime_ports::SessionExecutionTarget::local( + self.workspace.to_string_lossy().to_string(), + ), + ), + remote_connection_id: None, + remote_ssh_host: None, + }, }; + if let Err(error) = self.ensure_plugin_workspace_ready(&binding).await { + tracing::warn!( + "Configured plugin activation failed while loading Shared Runtime agent modes; continuing with native agents: {:?}", + error + ); + } + let workspace = PathBuf::from(&binding.workspace_path); + if let Err(error) = bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some(&workspace)).await { + tracing::warn!( + "Failed to initialize external agent sources for Shared TUI mode catalog: {}", + error + ); + } let modes = self .runtime .list_agent_modes(AgentModeCatalogQuery { @@ -258,6 +278,7 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .into_iter() .map(|mode| RuntimeAgentModeSummary { id: mode.id, + route_key: mode.route_key, description: mode.description, model_id: mode.model_id, is_external: mode.is_external, diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs index ad7b45c0d4..bfabc72ce3 100644 --- a/src/apps/cli/src/ui/agent_selector.rs +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -20,6 +20,7 @@ use crate::ui::{ #[derive(Debug, Clone)] pub(crate) struct AgentItem { pub id: String, + pub route_key: Option, pub description: String, } @@ -381,10 +382,12 @@ mod tests { vec![ AgentItem { id: "agentic".to_string(), + route_key: None, description: "General purpose".to_string(), }, AgentItem { id: "ask".to_string(), + route_key: None, description: "Read only".to_string(), }, ] diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 18a86dc755..8f955136d7 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -803,6 +803,7 @@ mod tests { view.show_agent_selector( vec![AgentItem { id: "agentic".to_string(), + route_key: None, description: "General purpose".to_string(), }], Some("agentic".to_string()), @@ -822,6 +823,7 @@ mod tests { view.show_agent_modes_only( vec![AgentItem { id: "agentic".to_string(), + route_key: None, description: "General purpose".to_string(), }], Some("agentic".to_string()), diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index bb81dcea81..137f42b46e 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -314,6 +314,12 @@ impl StartupPage { &self.agent_type } + pub(crate) fn selected_agent_route_key(&self) -> Option { + self.selected_agent_mode() + .map(|mode| mode.route_key) + .filter(|route_key| !route_key.is_empty()) + } + /// Set a model ID override (from `--model` flag) for display and session /// composition. The ID is validated when applied to the session; an invalid /// ID logs a warning and falls back to the default model. @@ -2016,6 +2022,7 @@ impl StartupPage { .into_iter() .map(|m| AgentItem { id: m.id, + route_key: (!m.route_key.is_empty()).then_some(m.route_key), description: m.description, }) .collect(); @@ -2728,12 +2735,14 @@ mod logo_contract_tests { fn external_or_unknown_startup_modes_do_not_change_the_shared_default() { let local = TuiAgentMode { id: "agentic".to_string(), + route_key: "agentic".to_string(), description: String::new(), model_id: None, is_external: false, }; let external = TuiAgentMode { id: "reviewer".to_string(), + route_key: "external::reviewer".to_string(), description: String::new(), model_id: None, is_external: true, diff --git a/src/apps/cli/src/ui/tool_cards.rs b/src/apps/cli/src/ui/tool_cards.rs index 3ffaa331e4..7875c8c6a5 100644 --- a/src/apps/cli/src/ui/tool_cards.rs +++ b/src/apps/cli/src/ui/tool_cards.rs @@ -530,7 +530,7 @@ fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> Strin format!("WebFetch {}", truncate_str(&url, 60)) } "Skill" => { - let name = param_str(&tool_state.parameters, &["name", "skill_name"]); + let name = skill_display_name(&tool_state.parameters); format!("Skill \"{}\"", name) } "Git" => { @@ -2056,6 +2056,11 @@ fn param_str(params: &serde_json::Value, keys: &[&str]) -> String { "unknown".to_string() } +/// Extract the Skill tool's command, retaining legacy parameter aliases. +fn skill_display_name(params: &serde_json::Value) -> String { + param_str(params, &["command", "name", "skill_name"]) +} + /// Extract an optional string parameter fn param_str_opt(params: &serde_json::Value, keys: &[&str]) -> Option { for key in keys { @@ -2108,3 +2113,15 @@ fn capitalize_first(s: &str) -> String { Some(c) => c.to_uppercase().to_string() + chars.as_str(), } } + +#[cfg(test)] +mod tests { + use super::skill_display_name; + + #[test] + fn skill_name_is_read_from_command_parameter() { + let params = serde_json::json!({"command": "arkts-error-fixes"}); + + assert_eq!(skill_display_name(¶ms), "arkts-error-fixes"); + } +} diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index b540b4e19f..0b0ce92f85 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1670,10 +1670,12 @@ pub async fn create_session( ) .await { - warn!( - "Configured workspace plugin activation failed before session creation: {}", - error - ); + bitfun_core::plugin_host::report_configured_plugin_activation_failure( + "Desktop session creation", + Some(std::path::Path::new(&resolved_execution_target.root_path)), + error, + ) + .await; } if is_idempotent_managed_create { @@ -1927,6 +1929,7 @@ pub async fn update_session_mode( .update_session_mode(AgentSessionModeUpdateRequest { session_id, mode_id: request.mode_id, + agent_route_key: None, }) .await .map_err(|error| format!("Failed to update session mode: {}", error.into_message())) @@ -3855,6 +3858,7 @@ pub async fn generate_session_title( #[tauri::command] pub async fn get_available_modes( state: State<'_, AppState>, + runtime: State<'_, DesktopRuntimeContext>, startup_trace: State<'_, DesktopStartupTrace>, request: Option, ) -> Result, String> { @@ -3865,9 +3869,21 @@ pub async fn get_available_modes( .as_deref() .filter(|path| !path.trim().is_empty()) .map(PathBuf::from); - let external_sources_supported = - mode_catalog_supports_external_sources(&request, workspace_path.as_deref()).await; - if external_sources_supported { + let local_scope = local_mode_catalog_scope(&request, workspace_path.as_deref()).await; + let external_sources_supported = local_scope.is_some(); + if let Some(scope) = local_scope { + if let Err(error) = runtime + .session_application() + .ensure_configured_plugin_instance(scope, None) + .await + { + bitfun_core::plugin_host::report_configured_plugin_activation_failure( + "Desktop mode catalog", + workspace_path.as_deref(), + error, + ) + .await; + } if let Err(error) = bitfun_core::external_sources::ensure_external_source_workspace_snapshot( workspace_path.as_deref(), @@ -3919,6 +3935,22 @@ pub struct GetAvailableModesRequest { pub remote_ssh_host: Option, } +async fn local_mode_catalog_scope( + request: &GetAvailableModesRequest, + workspace_path: Option<&Path>, +) -> Option { + if !mode_catalog_supports_external_sources(request, workspace_path).await { + return None; + } + workspace_path.map(|path| { + desktop_session_scope( + path.to_string_lossy().into_owned(), + request.remote_connection_id.clone(), + request.remote_ssh_host.clone(), + ) + }) +} + async fn mode_catalog_supports_external_sources( request: &GetAvailableModesRequest, workspace_path: Option<&Path>, @@ -4069,7 +4101,44 @@ mod tests { remote_ssh_host: Some("build-host".to_string()), }; - assert!(!mode_catalog_supports_external_sources(&request, Some(&desktop_host_path)).await); + assert!(local_mode_catalog_scope(&request, Some(&desktop_host_path)) + .await + .is_none()); + } + + #[tokio::test] + async fn local_mode_catalog_builds_the_plugin_activation_scope() { + let workspace = tempfile::tempdir().expect("workspace"); + let request = GetAvailableModesRequest { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + remote_connection_id: None, + remote_ssh_host: None, + }; + + let scope = local_mode_catalog_scope(&request, Some(workspace.path())) + .await + .expect("local mode catalog scope"); + assert_eq!(scope.workspace_path, request.workspace_path.unwrap()); + } + + #[test] + fn desktop_mode_catalog_activates_plugins_before_reading_the_registry() { + let source = include_str!("agentic_api.rs").replace("\r\n", "\n"); + let command = source + .split_once("pub async fn get_available_modes(") + .expect("mode catalog command") + .1 + .split_once("pub struct GetAvailableModesRequest") + .expect("mode catalog command boundary") + .0; + + let activation = command + .find(".ensure_configured_plugin_instance(") + .expect("configured plugin activation"); + let catalog_read = command + .find(".get_modes_info_for_workspace(") + .expect("registry mode catalog read"); + assert!(activation < catalog_read); } #[test] diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3290073a34..69f9fca31b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -93,7 +93,7 @@ use api::tool_api::*; use startup_trace::{DesktopStartupTrace, DesktopStartupTraceSnapshot}; pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = - bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled; + bitfun_core::plugin_host::PluginHostLaunchPolicy::Enabled; pub(crate) fn ensure_rustls_crypto_provider() { bitfun_core::service::remote_connect::ensure_rustls_crypto_provider(); @@ -640,6 +640,12 @@ pub async fn run() { Ok(status) => log::info!("Plugin host initialization completed: {:?}", status), Err(error) => { log::error!("Failed to initialize configured plugin host: {}", error); + bitfun_core::plugin_host::report_configured_plugin_activation_failure( + "Desktop startup", + None, + error, + ) + .await; } } startup_timings.record_elapsed("initialize_plugin_host", step_started); diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 3e7a5a6ff6..64005b3671 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -390,15 +390,20 @@ impl DesktopSessionApplication { &self, request: DesktopSessionScopeRequest, project_id: Option, - ) -> DesktopSessionApplicationResult> { + ) -> DesktopSessionApplicationResult<()> { let scope = self.resolved_scope(request).await; self.ensure_runtime_ownership(&scope)?; if scope.remote_connection_id.is_some() { - log::debug!( - "Configured plugin host activation skipped for remote workspace: workspace_path={}", + if !bitfun_core::plugin_host::configured_plugins_present() + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))? + { + return Ok(()); + } + return Err(DesktopSessionApplicationError::Core(format!( + "OpenCode plugin hooks are unavailable for remote workspace {} because the remote execution domain does not provide a plugin host", scope.workspace_path - ); - return Ok(None); + ))); } let workspace_path = PathBuf::from(&scope.workspace_path); bitfun_core::plugin_host::ensure_configured_plugin_instance( @@ -406,7 +411,6 @@ impl DesktopSessionApplication { workspace_path.clone(), workspace_path, project_id, - serde_json::Map::new(), ) .await .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 0a4c19a43e..7e8f6ba6e7 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -19,6 +19,7 @@ ], "resources": { "../../mobile-web/dist": "mobile-web/dist", + "../extension-host/dist/extension-host.js": "resources/ext-host/extension-host.js", "resources/worker_host.js": "resources/worker_host.js", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json index a14b754cfe..fdd4e9aba5 100644 --- a/src/apps/desktop/tauri.dev.conf.json +++ b/src/apps/desktop/tauri.dev.conf.json @@ -18,6 +18,7 @@ ], "resources": { "../../mobile-web/dist": "mobile-web/dist", + "../extension-host/dist/extension-host.js": "resources/ext-host/extension-host.js", "resources/worker_host.js": "resources/worker_host.js", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", diff --git a/src/apps/extension-host/PROTOCOL.md b/src/apps/extension-host/PROTOCOL.md index d171693f7d..14fd7f0bd5 100644 --- a/src/apps/extension-host/PROTOCOL.md +++ b/src/apps/extension-host/PROTOCOL.md @@ -43,7 +43,8 @@ The host sends: "token": "value from OPENCODE_EXTENSION_HOST_RPC_TOKEN", "protocolVersion": 1, "opencodeVersion": "1.17.18", - "maxFrameBytes": 16777216 + "maxFrameBytes": 16777216, + "capabilities": ["config-contributors-v1", "config-contributions-v2", "generation-fencing-v1"] } } ``` @@ -57,12 +58,15 @@ Rust returns: "result": { "protocolVersion": 1, "maxFrameBytes": 16777216, - "cacheDirectory": "/absolute/path/to/plugin-cache" + "cacheDirectory": "/absolute/path/to/plugin-cache", + "capabilities": ["config-contributors-v1", "config-contributions-v2", "generation-fencing-v1"] } } ``` -`cacheDirectory` must be absolute and writable by the host. It is the only location in which the host installs npm plugins. The accepted `maxFrameBytes` remains fixed until disconnect. +`cacheDirectory` must be absolute and writable by the host. It is the only location in which the host installs npm plugins. The accepted `maxFrameBytes` remains fixed until disconnect. Capabilities are intersected by both peers. Rust must not execute function hooks or plugin tools unless `generation-fencing-v1` was negotiated. Config projection requires `config-contributors-v1`; multiple Config contributors additionally require `config-contributions-v2`. + +Rust exposes the connection as ready only after the successful handshake response has been written. The host rejects `host.*` requests observed before a valid response. Its operational handlers are installed before the request is sent, so a first business request delivered in the same TCP read as the successful response waits for host construction instead of racing method registration. ## Common wire types @@ -145,12 +149,18 @@ One read returns no more than `maxBytes`, with a 64 KiB maximum. `eof: true` rel ### Process-local identity -`instanceID`, `executionID`, `flowID`, `fetchID`, `requestID`, and `streamID` have no durable meaning. Rust must keep them with their creating instance and connection. Closing an instance invalidates its active capabilities; losing the process invalidates all of them. +`instanceID`, `generationKey`, `revision`, `executionID`, `flowID`, `fetchID`, `requestID`, and `streamID` have no durable meaning. Rust must keep them with their creating instance and connection. `instanceID + generationKey + revision` is the lease for executable Hook and Tool calls. Closing an instance invalidates its active capabilities; losing the process invalidates all of them. ## Rust-to-host methods ### Instance lifecycle +#### `host.plugins.prepare` + +Params: `{ plugins, configurationFingerprint?, defaultBaseDirectory?, allowInstall? }`. The result contains the normalized `reviewed` declarations, successfully `prepared` entries, `failed` entries, diagnostics, and a stable `reviewDigest`. Each prepared entry includes its canonical identity and, when available, `contentHash`. + +The Host coalesces only concurrent preparation of the same declarations, configuration fingerprint, install policy, and effective default base directory. A settled result is discarded. `host.instance.open` resolves the graph again and compares `expectedReviewDigest` and `expectedContentDigests` before import, so a changed local file or package graph cannot reuse an earlier preparation snapshot. + #### `host.instance.open` Params: @@ -158,6 +168,8 @@ Params: ```ts { instanceID: string + generationKey: string + revision: string project: JsonValue config: Record directory: string @@ -167,6 +179,7 @@ Params: options?: Record baseDirectory?: string }> + configurationFingerprint?: string } ``` @@ -175,7 +188,18 @@ Result: ```ts { instanceID: string + generationKey: string + revision: string config: Record + configContributors: Array<{ + plugin: Record + outcome: "applied" | "failed" + }> + configContributions: Array<{ + plugin: Record + outcome: "applied" | "failed" + config: Record + }> diagnostics: Diagnostic[] hooks: string[] tools: Array<{ @@ -204,7 +228,9 @@ Result: The gateway is listening before plugin entrypoints execute, so SDK calls during initialization work. Config hooks run sequentially before the result is sent. Failed plugins are omitted and represented in `diagnostics`; successful registrations remain available. -Opening an active `instanceID` or a directory already owned by another instance is an error. Reopening after close creates a new instance and reruns entrypoints while preserving Bun's normal process-global module cache. +The host binds the requested `instanceID + generationKey + revision` only after the instance opens successfully and echoes all three values unchanged. Opening an active `instanceID` or a directory already owned by another instance is an error. Reopening after close creates a new instance and reruns entrypoints while preserving Bun's normal process-global module cache. + +`configContributors` records every plugin that declared a Config hook, in plugin activation order, and whether its invocation applied or failed. `configContributions` contains the same ordered entries plus a bounded clone of the cumulative config immediately after each hook. Config hooks still retain mutations made before an exception and continue to later contributors. Rust uses the sequence to attribute Agent, permission, and Skill changes without re-executing plugin code. The snapshots are protocol data and must not be written to ordinary logs. `hooks` may contain: @@ -230,7 +256,7 @@ Opening an active `instanceID` or a directory already owned by another instance Params: `{ instanceID }`. Result: `{ closed: boolean }`. -The host rejects new operations, aborts active tools and fetches, releases auth flows and streams, closes the gateway, and invokes every disposer once. Dispose failures are diagnostics and do not stop remaining cleanup. Repeated close is idempotent. +The host rejects new operations, aborts active tools and fetches, releases auth flows and streams, closes the gateway, and invokes every disposer once. Ordinary disposer failures are diagnostics and do not stop remaining cleanup. If an active Tool does not drain before the hard deadline, or a disposer itself exceeds its hard deadline, cleanup continues but the close request fails and the Host enters `closing`; Rust must retire that physical Host generation before opening a replacement. Repeated close after a confirmed cleanup is idempotent. #### `host.shutdown` @@ -242,10 +268,14 @@ The host closes all instances, responds, closes the RPC connection, and exits no #### `host.hook.call` -Params: `{ instanceID, hook, input, output }`. Result: `{ input, output }`. +Params: `{ instanceID, generationKey, revision, hook, input, output }`. + +Result: `{ instanceID, generationKey, revision, hook, input, output }`. `input` and `output` are JSON values. Matching hooks run sequentially in plugin order on the same live objects for this invocation. The first hook error stops the invocation; earlier mutations are not rolled back. Different hook requests may overlap. +The Host rejects a call whose generation lease does not exactly match the open instance. Rust likewise rejects a response that does not echo the requested instance, generation, revision, and hook name. + For `tool.definition`, `output.parameters` crosses the process boundary as JSON Schema rather than an Effect schema object. #### `host.event.emit` @@ -263,6 +293,8 @@ Params: ```ts { instanceID: string + generationKey: string + revision: string executionID: string registrationID: string args: JsonValue @@ -297,13 +329,13 @@ The host reconstructs a per-execution `AbortSignal` and fills the public tool co Tool registration parameters and later `tool.definition` parameters use their JSON Schema projection. Rust sends arguments; Bun validates them through the retained plugin schema before execution. -Rust invokes the tool by the returned opaque `registrationID`. `id` is the plugin-facing tool name and is not an execution handle. +Rust invokes the tool by the returned opaque `registrationID`. `id` is the plugin-facing tool name and is not an execution handle. The Host validates the complete generation lease before admitting execution and echoes `instanceID`, `generationKey`, `revision`, and `executionID` in the result wrapper together with the plugin `result`. #### `host.tool.cancel` -Params: `{ instanceID, executionID }`. Result: `{ cancelled: boolean }`. +Params: `{ instanceID, generationKey, revision, executionID, reason? }`. Result: `{ cancelled: boolean }`. -The host aborts the retained signal. Cancellation is idempotent and does not hard-kill subprocesses created by a plugin. +The host aborts the retained signal. Cancellation is idempotent and does not hard-kill subprocesses created by a plugin. `cancelled: true` means the invocation stopped after observing the abort; it does not prove that earlier filesystem, network, process, or other side effects were rolled back. Rust therefore reports a dispatched cancellation as outcome-unknown and must not automatically retry it. ### Auth @@ -453,6 +485,8 @@ Params: ```ts { instanceID: string + generationKey: string + revision: string executionID: string permission: string patterns: string[] @@ -465,7 +499,7 @@ Result: `{}` on approval, or a JSON-RPC error on denial/failure. The host awaits ### `backend.tool.metadata` -Notification params: `{ instanceID, executionID, title?, metadata? }`. Because this is a notification, the plugin's `metadata(...)` call returns without waiting for Rust. +Notification params: `{ instanceID, generationKey, revision, executionID, title?, metadata? }`. Because this is a notification, the plugin's `metadata(...)` call returns without waiting for Rust. ### `backend.diagnostic.publish` diff --git a/src/apps/extension-host/README.md b/src/apps/extension-host/README.md index f5abc28eea..9a3a6799a8 100644 --- a/src/apps/extension-host/README.md +++ b/src/apps/extension-host/README.md @@ -26,6 +26,8 @@ It deliberately does **not** support: Local plugins are imported in place and must already be able to resolve their runtime dependencies. npm plugins are installed into the cache directory supplied by Rust during the handshake, with lifecycle scripts disabled. The host never installs into or edits a local plugin project. +Configured plugin declarations are therefore an explicit trusted-code opt-in, not a sandbox or a fine-grained activation grant. The current reliability controls (loopback authentication, generation fencing, deadlines, cancellation, and process-tree cleanup) remain enabled, while immutable import snapshots, provenance, activation/permission UX, credential isolation, per-plugin isolation, and resource quotas are tracked as follow-up security work in [`opencode-extension-compatibility.md`](../../docs/architecture/extensions/opencode-extension-compatibility.md#21-当前受管-package-plugin-运行切片). + ## Architecture Rust first binds a loopback TCP listener and then launches the host. The host connects to the address in `OPENCODE_EXTENSION_HOST_RPC_ADDRESS` and authenticates its first request with `OPENCODE_EXTENSION_HOST_RPC_TOKEN`. @@ -40,7 +42,7 @@ Rust backend <====== framed bidirectional JSON-RPC ==+ Control traffic uses JSON-RPC 2.0 messages framed by a four-byte big-endian length. Requests can travel in either direction and may be reentrant; plugin stdout and stderr are never used as protocol channels. HTTP and fetch bodies use pull-based stream handles so the receiver controls backpressure instead of embedding unbounded bodies in JSON. -Each `host.instance.open` call creates one logical plugin instance and one HTTP gateway. The host resolves and imports retained plugin declarations concurrently, executes successful entrypoints in declaration order, runs their config hooks, and returns the resulting registrations. Operational hook calls are ordered within one invocation, but unrelated invocations and unrelated instances may overlap. +Each `host.instance.open` call creates one logical plugin instance and one HTTP gateway. The host resolves and imports retained plugin declarations concurrently, executes successful entrypoints in declaration order, runs their config hooks, and returns the resulting registrations. The open result includes ordered cumulative Config snapshots so Rust can attribute Agent, permission, and Skill changes across multiple Config hooks without re-executing plugin code. Operational hook calls are ordered within one invocation, but unrelated invocations and unrelated instances may overlap. Closing an instance rejects new work, cancels active tools and fetches, closes its gateway, and invokes every registered disposer once. Losing the RPC connection applies the same cleanup to all instances and terminates the host. Rust is responsible for restarting the process and deciding whether any application work should be retried. @@ -49,6 +51,12 @@ See [PROTOCOL.md](./PROTOCOL.md) for the complete method, error, and wire contra ## Build and launch The directory has its own dependency lock and does not rely on workspace-internal OpenCode packages. +Official Desktop, CLI, and app-server development/build entry points run +`pnpm run plugin-host:prepare`; Desktop and CLI packages plus app-server release +output stage the resulting `extension-host.js` under `resources/ext-host`. The current execution backend +still requires a compatible `bun` command at runtime (or an explicit +`BITFUN_BUN_COMMAND`). Bundling a signed Bun sidecar is separate distribution +work and must be completed before claiming a self-contained installer. ```sh cd src/apps/extension-host diff --git a/src/apps/extension-host/examples/example-plugin.ts b/src/apps/extension-host/examples/example-plugin.ts index 8c2e90fdd7..7a4409b833 100644 --- a/src/apps/extension-host/examples/example-plugin.ts +++ b/src/apps/extension-host/examples/example-plugin.ts @@ -1,5 +1,6 @@ import type { Plugin, PluginModule } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin" +import { z } from "zod" const ExamplePlugin: Plugin = async (input) => { input.experimental_workspace.register("example-local", { @@ -23,7 +24,52 @@ const ExamplePlugin: Plugin = async (input) => { }) return { + config: async (config) => { + config.bitfunDemo = { + enabled: true, + directory: input.directory, + } + config.agent = { + ...(config.agent ?? {}), + Cowork: { + mode: "primary", + description: "Demo agent for exercising the BitFun OpenCode plugin bridge", + prompt: "Use the bitfun_demo_echo tool when the user asks you to echo text.", + permission: { + bitfun_demo_echo: "allow", + }, + }, + } + }, tool: { + bitfun_demo_echo: tool({ + description: "Echo text from the BitFun OpenCode plugin demo", + args: { + text: z.string().describe("Text to echo"), + }, + async execute(args, context) { + context.metadata({ + title: "Preparing BitFun demo echo", + metadata: { phase: "before-ask" }, + }) + await context.ask({ + permission: "bitfun_demo_echo", + patterns: ["demo-echo"], + always: ["demo-echo"], + metadata: { + riskDescription: "Allow the demo plugin to echo text", + }, + }) + context.metadata({ + title: "BitFun demo echo approved", + metadata: { phase: "after-ask" }, + }) + return { + title: "BitFun demo echo", + output: `${args.text}:${input.directory}`, + } + }, + }), extension_host_info: tool({ description: "Exercise the injected OpenCode SDK client and raw HTTP gateway", args: {}, diff --git a/src/apps/extension-host/package.json b/src/apps/extension-host/package.json index 5bf85af17c..b426b7e370 100644 --- a/src/apps/extension-host/package.json +++ b/src/apps/extension-host/package.json @@ -8,7 +8,7 @@ "scripts": { "build": "bun build ./src/main.ts --target=bun --outfile=dist/extension-host.js", "generate": "bun run ./script/generate-protocol.ts", - "test": "bun test", + "test": "bun test --silent ./test/boundary.test.ts && bun test --silent ./test/gateway.test.ts && bun test --silent ./test/host.test.ts && bun test --silent ./test/loader.test.ts && bun test --silent ./test/loopback.test.ts && bun test --silent ./test/process.test.ts && bun test --silent ./test/rpc.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/src/apps/extension-host/protocol.schema.json b/src/apps/extension-host/protocol.schema.json index 4244566483..140209f048 100644 --- a/src/apps/extension-host/protocol.schema.json +++ b/src/apps/extension-host/protocol.schema.json @@ -296,6 +296,9 @@ }, "defaultBaseDirectory": { "type": "string" + }, + "allowInstall": { + "type": "boolean" } }, "required": [ @@ -343,6 +346,55 @@ "type": "string", "minLength": 1 }, + "reviewDigest": { + "type": "string", + "minLength": 64, + "maxLength": 64 + }, + "reviewed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "file", + "npm" + ] + }, + "identity": { + "type": "string", + "minLength": 1 + }, + "canonicalSource": { + "type": "string", + "minLength": 1 + }, + "baseDirectory": { + "type": "string", + "minLength": 1 + }, + "optionsDigest": { + "type": "string", + "minLength": 64, + "maxLength": 64 + } + }, + "required": [ + "spec", + "source", + "identity", + "canonicalSource", + "baseDirectory", + "optionsDigest" + ], + "additionalProperties": false + } + }, "prepared": { "type": "array", "items": { @@ -351,6 +403,10 @@ "spec": { "type": "string" }, + "identity": { + "type": "string", + "minLength": 1 + }, "source": { "type": "string", "enum": [ @@ -372,12 +428,18 @@ "validated" ] }, + "contentHash": { + "type": "string", + "minLength": 64, + "maxLength": 64 + }, "version": { "type": "string" } }, "required": [ "spec", + "identity", "source", "target", "entry", @@ -458,6 +520,8 @@ } }, "required": [ + "reviewDigest", + "reviewed", "prepared", "failed", "diagnostics" @@ -504,6 +568,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "project": { "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" }, @@ -553,6 +625,22 @@ "configurationFingerprint": { "type": "string", "minLength": 1 + }, + "expectedContentDigests": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "minLength": 64, + "maxLength": 64 + } + }, + "expectedReviewDigest": { + "type": "string", + "minLength": 64, + "maxLength": 64 } }, "required": [ @@ -605,6 +693,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "config": { "type": "object", "propertyNames": { @@ -614,6 +710,110 @@ "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" } }, + "configContributors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "plugin": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "spec": { + "type": "string", + "minLength": 1 + }, + "entry": { + "type": "string", + "minLength": 1 + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "spec", + "entry", + "index" + ], + "additionalProperties": false + }, + "outcome": { + "type": "string", + "enum": [ + "applied", + "failed" + ] + } + }, + "required": [ + "plugin", + "outcome" + ], + "additionalProperties": false + } + }, + "configContributions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "plugin": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "spec": { + "type": "string", + "minLength": 1 + }, + "entry": { + "type": "string", + "minLength": 1 + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "spec", + "entry", + "index" + ], + "additionalProperties": false + }, + "outcome": { + "type": "string", + "enum": [ + "applied", + "failed" + ] + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + } + }, + "required": [ + "plugin", + "outcome", + "config" + ], + "additionalProperties": false + } + }, "diagnostics": { "type": "array", "items": { @@ -925,6 +1125,8 @@ "required": [ "instanceID", "config", + "configContributors", + "configContributions", "diagnostics", "hooks", "tools", @@ -1057,6 +1259,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "hook": { "type": "string", "minLength": 1 @@ -1112,6 +1322,22 @@ "HostHookCallResult": { "type": "object", "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, + "hook": { + "type": "string", + "minLength": 1 + }, "input": { "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" }, @@ -1120,6 +1346,7 @@ } }, "required": [ + "hook", "input", "output" ], @@ -1228,6 +1455,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 @@ -1306,62 +1541,88 @@ } }, "HostToolExecuteResult": { - "anyOf": [ - { - "type": "string" + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 }, - { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "output": { + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "result": { + "anyOf": [ + { "type": "string" }, - "metadata": { + { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "url": { + "properties": { + "title": { + "type": "string" + }, + "output": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { "type": "string" }, - "filename": { - "type": "string" + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" } }, - "required": [ - "type", - "mime", - "url" - ], - "additionalProperties": false - } + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "type", + "mime", + "url" + ], + "additionalProperties": false + } + } + }, + "required": [ + "output" + ], + "additionalProperties": false } - }, - "required": [ - "output" - ], - "additionalProperties": false + ] } + }, + "required": [ + "executionID", + "result" ], + "additionalProperties": false, "$defs": { "__schema0": { "anyOf": [ @@ -1403,6 +1664,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 @@ -2667,8 +2936,15 @@ }, "maxFrameBytes": { "type": "integer", - "minimum": 65536, + "exclusiveMinimum": 0, "maximum": 67108864 + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, "required": [ @@ -2688,11 +2964,18 @@ }, "maxFrameBytes": { "type": "integer", - "minimum": 65536, + "exclusiveMinimum": 0, "maximum": 67108864 }, "cacheDirectory": { "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, "required": [ @@ -2883,6 +3166,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 @@ -2967,6 +3258,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 diff --git a/src/apps/extension-host/src/bun-loader.ts b/src/apps/extension-host/src/bun-loader.ts index 9a43ff9cd6..3b460fa852 100644 --- a/src/apps/extension-host/src/bun-loader.ts +++ b/src/apps/extension-host/src/bun-loader.ts @@ -29,11 +29,21 @@ export function installNpmPlugin(input: Parameters[0]): Promise installs.delete(directory)) installs.set(directory, operation) return operation } +async function reviewCachedNpmPlugin(input: Parameters[0], directory: string) { + const existing = await installedPackage(directory, input.packageName) + if (existing) { + logEvent("plugin.prepare.cache_hit", { plugin: input.spec, target: existing }, "debug") + return { target: existing, cache: "hit" as const } + } + throw new Error(`Plugin ${input.spec} is not cached; activation review requires installation approval`) +} + async function installNpmPluginAt(input: Parameters[0], directory: string) { await mkdir(directory, { recursive: true }) const manifestPath = path.join(directory, "package.json") diff --git a/src/apps/extension-host/src/host.ts b/src/apps/extension-host/src/host.ts index 7b064a5cf0..f8e2d6d060 100644 --- a/src/apps/extension-host/src/host.ts +++ b/src/apps/extension-host/src/host.ts @@ -1,5 +1,6 @@ import path from "node:path" import { realpath } from "node:fs/promises" +import { createHash } from "node:crypto" import type { AuthHook, AuthOAuthResult, @@ -121,8 +122,16 @@ type ActiveAuthFetch = { descriptor?: StreamDescriptor } +type ActiveTool = { + controller: AbortController + done: Promise + stoppedAfterAbort: boolean +} + type Instance = { id: string + generationKey?: string + revision?: string canonicalDirectory: string directory: string worktree: string @@ -135,7 +144,8 @@ type Instance = { workspaces: Map flows: Map fetches: Map - activeTools: Map + activeTools: Map + activeHooks: Set> activeFetches: Map openDone: Promise finishOpen(): void @@ -146,18 +156,23 @@ type Instance = { export type InstanceOpenInput = { instanceID: string + generationKey?: string + revision?: string project: WireValue directory: string worktree: string config: WireValue plugins: PluginDeclaration[] configurationFingerprint?: string + expectedContentDigests?: Record + expectedReviewDigest?: string } export type PluginsPrepareInput = { plugins: PluginDeclaration[] configurationFingerprint?: string defaultBaseDirectory?: string + allowInstall?: boolean } export class ExtensionHost { @@ -197,17 +212,28 @@ export class ExtensionHost { declarations: input.plugins, defaultBaseDirectory: input.defaultBaseDirectory, configurationFingerprint: input.configurationFingerprint, + allowInstall: input.allowInstall, }) - return HostMethodSchemas["host.plugins.prepare"].result.parse({ + const result = { ...(input.configurationFingerprint ? { configurationFingerprint: input.configurationFingerprint } : {}), + reviewed: prepared.reviewed.map((plugin) => ({ + spec: plugin.spec, + source: plugin.source, + identity: plugin.identity, + canonicalSource: plugin.resolvedSpec, + baseDirectory: plugin.baseDirectory, + optionsDigest: plugin.optionsDigest, + })), prepared: prepared.prepared.map((plugin) => ({ spec: plugin.spec, + identity: plugin.identity, source: plugin.source, target: plugin.target, entry: plugin.entry, cache: plugin.cache, + ...(plugin.contentHash ? { contentHash: plugin.contentHash } : {}), ...(typeof plugin.package?.manifest.version === "string" ? { version: plugin.package.manifest.version } : {}), @@ -218,6 +244,10 @@ export class ExtensionHost { message: diagnostic.message, })), diagnostics: prepared.diagnostics.map(protocolDiagnostic), + } + return HostMethodSchemas["host.plugins.prepare"].result.parse({ + ...result, + reviewDigest: preparationReviewDigest(result), }) } @@ -251,7 +281,7 @@ export class ExtensionHost { }) } - const config = cloneWireValue(input.config, "config") + let config = cloneWireValue(input.config, "config") const gateway = await this.#gatewayFactory({ instanceID: input.instanceID, rpc: this.#rpc, @@ -260,6 +290,8 @@ export class ExtensionHost { const opened = Promise.withResolvers() const instance: Instance = { id: input.instanceID, + generationKey: input.generationKey, + revision: input.revision, canonicalDirectory, directory: input.directory, worktree: input.worktree, @@ -273,6 +305,7 @@ export class ExtensionHost { flows: new Map(), fetches: new Map(), activeTools: new Map(), + activeHooks: new Set(), activeFetches: new Map(), openDone: opened.promise, finishOpen: opened.resolve, @@ -294,7 +327,59 @@ export class ExtensionHost { declarations: input.plugins, defaultBaseDirectory: input.directory, configurationFingerprint: input.configurationFingerprint, + allowInstall: true, }) + if (input.expectedReviewDigest) { + const reviewDigest = preparationReviewDigest({ + ...(input.configurationFingerprint + ? { configurationFingerprint: input.configurationFingerprint } + : {}), + reviewed: prepared.reviewed.map((plugin) => ({ + spec: plugin.spec, + source: plugin.source, + identity: plugin.identity, + canonicalSource: plugin.resolvedSpec, + baseDirectory: plugin.baseDirectory, + optionsDigest: plugin.optionsDigest, + })), + prepared: prepared.prepared.map((plugin) => ({ + spec: plugin.spec, + identity: plugin.identity, + source: plugin.source, + target: plugin.target, + entry: plugin.entry, + ...(plugin.contentHash ? { contentHash: plugin.contentHash } : {}), + ...(typeof plugin.package?.manifest.version === "string" + ? { version: plugin.package.manifest.version } + : {}), + })), + failed: prepared.diagnostics.map((diagnostic) => ({ + spec: diagnostic.spec, + stage: diagnostic.stage, + })), + }) + if (reviewDigest !== input.expectedReviewDigest) { + throw new ExtensionHostError(-32004, "Plugin preparation changed before import", { + kind: "prepared_review_changed", + }) + } + } + if (input.expectedContentDigests) { + for (const plugin of prepared.prepared) { + const expected = input.expectedContentDigests[plugin.identity] + if (!expected || !plugin.contentHash || expected !== plugin.contentHash) { + throw new ExtensionHostError(-32004, `Plugin ${plugin.spec} content changed before import`, { + kind: "prepared_content_changed", + plugin: plugin.spec, + }) + } + } + if (prepared.prepared.length !== Object.keys(input.expectedContentDigests).length) { + throw new ExtensionHostError(-32004, "Prepared plugin graph changed before import", { + kind: "prepared_graph_changed", + }) + } + } const loaded = await loadPreparedPlugins(prepared) this.#assertOpening(instance) diagnostics.push(...loaded.diagnostics) @@ -315,14 +400,43 @@ export class ExtensionHost { diagnostic_count: diagnostics.length, }) + const configContributions: Array<{ + plugin: PluginMeta + outcome: "applied" | "failed" + config: WireValue + }> = [] for (const retained of instance.hooks) { this.#assertOpening(instance) if (!retained.hooks.config) continue + const configBeforeHook = cloneWireValue(config, "configBeforeHook") + logEvent("plugin.activation.config_hook.begin", { + instance_id: instance.id, + plugin: retained.plugin.spec, + }, "debug") try { await Promise.resolve(retained.hooks.config(config as never)) + configContributions.push({ + plugin: retained.plugin, + outcome: "applied", + config: cloneWireValue(config, "configContribution.config"), + }) + logEvent("plugin.activation.config_hook.complete", { + instance_id: instance.id, + plugin: retained.plugin.spec, + }, "debug") } catch (error) { + config = configBeforeHook const diagnostic = runtimeDiagnostic(retained.plugin, "config", error) diagnostics.push(diagnostic) + configContributions.push({ + plugin: retained.plugin, + outcome: "failed", + config: cloneWireValue(config, "configContribution.config"), + }) + logError("plugin.activation.config_hook.failed", error, { + instance_id: instance.id, + plugin: retained.plugin.spec, + }) await publishDiagnostic(this.#rpc, toPublishedDiagnostic(instance.id, diagnostic)).catch(() => {}) } this.#assertOpening(instance) @@ -331,7 +445,16 @@ export class ExtensionHost { this.#assertOpening(instance) this.#indexRegistrations(instance, diagnostics) this.#assertOpening(instance) - const result = HostMethodSchemas["host.instance.open"].result.parse(openResult(instance, config, diagnostics)) + const result = HostMethodSchemas["host.instance.open"].result.parse( + openResult(instance, config, diagnostics, configContributions), + ) + logEvent("plugin.activation.registrations", { + instance_id: instance.id, + hook_count: result.hooks.length, + config_hook_count: instance.hooks.filter(({ hooks }) => typeof hooks.config === "function").length, + tool_count: result.tools.length, + diagnostic_count: result.diagnostics.length, + }) instance.status = "open" return result } catch (error) { @@ -390,6 +513,7 @@ export class ExtensionHost { declarations: readonly PluginDeclaration[] defaultBaseDirectory?: string configurationFingerprint?: string + allowInstall?: boolean }) { const key = preparationKey(input) const pending = this.#preparations.get(key) @@ -413,6 +537,7 @@ export class ExtensionHost { cacheDirectory: this.#cacheDirectory, defaultBaseDirectory: input.defaultBaseDirectory, compatibilityVersion: OPENCODE_VERSION, + allowInstall: input.allowInstall, }) .then((result) => { for (const diagnostic of result.diagnostics) { @@ -442,28 +567,52 @@ export class ExtensionHost { }) throw error }) + // Preparation de-duplicates only concurrent callers. Keeping a settled + // result here would let a later open validate an old content digest + // against the same cached snapshot after a local plugin changed. + .finally(() => { + this.#preparations.delete(key) + }) this.#preparations.set(key, operation) return operation } - async callHook(input: { instanceID: string; name: string; input: WireValue; output: WireValue }) { + async callHook(input: { + instanceID: string + generationKey?: string + revision?: string + name: string + input: WireValue + output: WireValue + }) { const instance = this.#instance(input.instanceID) + assertGeneration(instance, input.generationKey, input.revision) const hookInput = cloneWireValue(input.input, "input") const hookOutput = cloneWireValue(input.output, "output") - - for (const retained of instance.hooks) { - const hook = Reflect.get(retained.hooks, input.name) - if (typeof hook !== "function") continue - try { - await Promise.resolve(hook(hookInput, hookOutput)) - } catch (error) { - throw pluginError(retained.plugin, input.name, error) + const completed = Promise.withResolvers() + instance.activeHooks.add(completed.promise) + try { + for (const retained of instance.hooks) { + const hook = Reflect.get(retained.hooks, input.name) + if (typeof hook !== "function") continue + try { + await Promise.resolve(hook(hookInput, hookOutput)) + } catch (error) { + throw pluginError(retained.plugin, input.name, error) + } } - } - return { - input: cloneWireValue(hookInput, "input"), - output: cloneWireValue(hookOutput, "output"), + return { + instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, + hook: input.name, + input: cloneWireValue(hookInput, "input"), + output: cloneWireValue(hookOutput, "output"), + } + } finally { + instance.activeHooks.delete(completed.promise) + completed.resolve() } } @@ -492,6 +641,8 @@ export class ExtensionHost { async executeTool(input: { instanceID: string + generationKey?: string + revision?: string registrationID: string executionID: string args: WireValue @@ -503,6 +654,7 @@ export class ExtensionHost { } }) { const instance = this.#instance(input.instanceID) + assertGeneration(instance, input.generationKey, input.revision) const registration = findRegistration(instance.tools, input.registrationID) if (!registration) throw missingHandle("tool", input.registrationID) if (instance.activeTools.has(input.executionID)) { @@ -510,7 +662,13 @@ export class ExtensionHost { } const controller = new AbortController() - instance.activeTools.set(input.executionID, controller) + const completed = Promise.withResolvers() + const active: ActiveTool = { + controller, + done: completed.promise, + stoppedAfterAbort: false, + } + instance.activeTools.set(input.executionID, active) try { const args = validateToolArguments(registration.definition.args, cloneWireValue(input.args, "args")) const result = await registration.definition.execute(args as never, { @@ -521,6 +679,8 @@ export class ExtensionHost { metadata: (metadata) => { const pending = this.#rpc.notify("backend.tool.metadata", { instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, executionID: input.executionID, ...(cloneWireValue(metadata, "metadata") as Record), }) @@ -531,6 +691,8 @@ export class ExtensionHost { "backend.tool.ask", { instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, executionID: input.executionID, ...(cloneWireValue(request, "request") as Record), }, @@ -538,19 +700,30 @@ export class ExtensionHost { ) }, }) - return cloneWireValue(result, "result") + return { + instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, + executionID: input.executionID, + result: cloneWireValue(result, "result"), + } } catch (error) { + if (controller.signal.aborted) active.stoppedAfterAbort = true throw pluginError(registration.plugin, `tool:${registration.id}`, error) } finally { instance.activeTools.delete(input.executionID) + completed.resolve() } } - cancelTool(input: { instanceID: string; executionID: string; reason?: string }) { - const controller = this.#instance(input.instanceID).activeTools.get(input.executionID) - if (!controller) return { cancelled: false } - controller.abort(input.reason) - return { cancelled: true } + async cancelTool(input: { instanceID: string; generationKey?: string; revision?: string; executionID: string; reason?: string }) { + const instance = this.#instance(input.instanceID) + assertGeneration(instance, input.generationKey, input.revision) + const active = instance.activeTools.get(input.executionID) + if (!active) return { cancelled: false } + active.controller.abort(input.reason) + await active.done + return { cancelled: active.stoppedAfterAbort } } evaluateAuthPrompt(input: { @@ -823,12 +996,20 @@ export class ExtensionHost { #beginClose(instance: Instance) { if (instance.closePromise) return instance.closePromise instance.status = "closing" - instance.closePromise = this.#disposeInstance(instance) + instance.closePromise = (async () => { + await instance.openDone + await this.#disposeInstance(instance) + })() return instance.closePromise } async #disposeInstance(instance: Instance) { - for (const controller of instance.activeTools.values()) controller.abort("Instance closed") + let lifecycleTimeout: unknown + const activeHookDrains = [...instance.activeHooks] + const activeToolDrains = [...instance.activeTools.values()].map((active) => { + active.controller.abort("Instance closed") + return active.done + }) for (const active of instance.activeFetches.values()) { active.controller.abort("Instance closed") void active.body?.cancel("Instance closed").catch(() => {}) @@ -836,6 +1017,45 @@ export class ExtensionHost { void this.#streams.cancelRemote?.(instance.id, active.descriptor, "Instance closed").catch(() => {}) } } + // Do not forget active calls after aborting them. A plugin may ignore the + // signal, so the host must keep the instance fenced until every call has + // settled; the Rust owner will terminate the process when this bounded + // dispose phase exceeds its deadline. + try { + await promiseWithDeadline( + Promise.allSettled(activeHookDrains).then(() => undefined), + 2_000, + "Plugin hook drain timed out", + ) + } catch (error) { + lifecycleTimeout = error + this.#status = "closing" + await publishDiagnostic(this.#rpc, { + level: "error", + message: `Instance ${instance.id} active hook drain failed`, + instanceID: instance.id, + operation: "dispose", + error: errorData(error), + }).catch(() => {}) + } + try { + await promiseWithDeadline( + Promise.allSettled(activeToolDrains).then(() => undefined), + 2_000, + "Plugin tool drain timed out", + ) + } catch (error) { + lifecycleTimeout = error + this.#status = "closing" + await publishDiagnostic(this.#rpc, { + level: "error", + message: `Instance ${instance.id} active tool drain failed`, + instanceID: instance.id, + operation: "dispose", + error: errorData(error), + }).catch(() => {}) + } + instance.activeHooks.clear() instance.activeTools.clear() instance.activeFetches.clear() try { @@ -850,7 +1070,6 @@ export class ExtensionHost { }).catch(() => {}) } finally { await instance.gateway.close().catch(() => {}) - await instance.openDone instance.flows.clear() instance.fetches.clear() @@ -859,8 +1078,12 @@ export class ExtensionHost { instance.disposed.add(retained) if (!retained.hooks.dispose) continue try { - await Promise.resolve(retained.hooks.dispose()) + await promiseWithDeadline(Promise.resolve(retained.hooks.dispose()), 2_000, "Plugin dispose timed out") } catch (error) { + if (error instanceof Error && error.message === "Plugin dispose timed out") { + lifecycleTimeout ??= error + this.#status = "closing" + } await publishDiagnostic(this.#rpc, { level: "error", message: `Plugin ${retained.plugin.spec} dispose hook failed`, @@ -877,6 +1100,7 @@ export class ExtensionHost { this.#directories.delete(instance.canonicalDirectory) } } + if (lifecycleTimeout) throw lifecycleTimeout } #instance(instanceID: string) { @@ -980,10 +1204,37 @@ export class ExtensionHost { } } -function openResult(instance: Instance, config: WireValue, diagnostics: HostDiagnostic[]) { +async function promiseWithDeadline(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +function openResult( + instance: Instance, + config: WireValue, + diagnostics: HostDiagnostic[], + configContributions: Array<{ + plugin: PluginMeta + outcome: "applied" | "failed" + config: WireValue + }>, +) { return { instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, config: cloneWireValue(config, "config"), + configContributors: configContributions.map(({ plugin, outcome }) => ({ plugin, outcome })), + configContributions, diagnostics: diagnostics.map(protocolDiagnostic), gatewayURL: instance.gateway.url.toString(), hooks: GENERIC_HOOKS.filter((name) => @@ -1012,6 +1263,14 @@ function openResult(instance: Instance, config: WireValue, diagnostics: HostDiag } } +function assertGeneration(instance: Instance, generationKey?: string, revision?: string) { + if (instance.generationKey === generationKey && instance.revision === revision) return + throw new ExtensionHostError(-32002, `Generation lease does not match instance ${instance.id}`, { + kind: "generation_mismatch", + instanceID: instance.id, + }) +} + function authDescriptor(provider: string, registration: AuthRegistration) { return { provider, @@ -1062,17 +1321,67 @@ function preparationKey(input: { declarations: readonly PluginDeclaration[] defaultBaseDirectory?: string configurationFingerprint?: string + allowInstall?: boolean }) { - const needsDefaultBaseDirectory = input.declarations.some( - ({ spec, baseDirectory }) => !baseDirectory && (spec.startsWith(".") || spec.startsWith("file:")), - ) + const needsDefaultBaseDirectory = input.declarations.some(({ baseDirectory }) => !baseDirectory) return JSON.stringify({ configurationFingerprint: input.configurationFingerprint, + allowInstall: input.allowInstall ?? true, declarations: input.declarations, ...(needsDefaultBaseDirectory ? { defaultBaseDirectory: input.defaultBaseDirectory } : {}), }) } +function preparationReviewDigest(input: { + configurationFingerprint?: string + reviewed: Array<{ + spec: string + source: string + identity: string + canonicalSource: string + baseDirectory: string + optionsDigest: string + }> + prepared: Array<{ + spec: string + identity: string + source: string + target: string + entry: string + contentHash?: string + version?: string + }> + failed: Array<{ spec: string; stage: string }> +}) { + // Project only stable approval facts. Cache hit/installed state and + // diagnostic prose are operational details and must not make the same + // executable graph require a different approval at open time. + const material = { + ...(input.configurationFingerprint + ? { configurationFingerprint: input.configurationFingerprint } + : {}), + reviewed: input.reviewed.map((plugin) => ({ + spec: plugin.spec, + source: plugin.source, + identity: plugin.identity, + canonicalSource: plugin.canonicalSource, + baseDirectory: plugin.baseDirectory, + optionsDigest: plugin.optionsDigest, + })), + prepared: input.prepared.map((plugin) => ({ + spec: plugin.spec, + identity: plugin.identity, + source: plugin.source, + target: plugin.target, + entry: plugin.entry, + ...(plugin.contentHash ? { contentHash: plugin.contentHash } : {}), + ...(plugin.version ? { version: plugin.version } : {}), + })), + failed: input.failed.map((failure) => ({ spec: failure.spec, stage: failure.stage })), + } + return createHash("sha256").update(JSON.stringify(material)).digest("hex") +} + function findRegistration(map: Map, registrationID: string) { return Array.from(map.values()).find((registration) => registration.registrationID === registrationID) } diff --git a/src/apps/extension-host/src/loader.ts b/src/apps/extension-host/src/loader.ts index 0a92ae4566..571b0b0d53 100644 --- a/src/apps/extension-host/src/loader.ts +++ b/src/apps/extension-host/src/loader.ts @@ -1,6 +1,7 @@ -import { readFile, realpath, stat } from "node:fs/promises" +import { readFile, readdir, realpath, stat } from "node:fs/promises" import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" +import { createHash } from "node:crypto" import type { Plugin, PluginOptions } from "@opencode-ai/plugin" import npmPackageArg from "npm-package-arg" import semver from "semver" @@ -24,6 +25,7 @@ export type NormalizedPluginDeclaration = { spec: string resolvedSpec: string identity: string + optionsDigest: string source: PluginSource packageName?: string options?: PluginOptions @@ -54,6 +56,7 @@ export type PreparedPlugin = NormalizedPluginDeclaration & { target: string entry: string cache: PluginCacheStatus + contentHash?: string package?: PluginPackage } @@ -79,6 +82,7 @@ export type NpmInstaller = (input: { spec: string packageName?: string cacheDirectory: string + allowInstall?: boolean }) => Promise }> export type LoadPluginsInput = { @@ -89,6 +93,7 @@ export type LoadPluginsInput = { install?: NpmInstaller readJson?: (file: string) => Promise> satisfies?: (version: string, range: string) => boolean + allowInstall?: boolean } export type LoadPluginsResult = { @@ -97,6 +102,7 @@ export type LoadPluginsResult = { } export type PreparePluginsResult = { + reviewed: NormalizedPluginDeclaration[] prepared: PreparedPlugin[] diagnostics: LoaderDiagnostic[] } @@ -127,11 +133,13 @@ export async function preparePlugins(input: LoadPluginsInput): Promise ("prepared" in result ? [result.prepared] : [])), diagnostics: [ ...normalized.diagnostics, @@ -220,7 +228,7 @@ export function parseNpmPluginSpecifier(spec: string, baseDirectory = process.cw const canonical = parsed.saveSpec ?? parsed.fetchSpec ?? parsed.raw const installSpec = parsed.type === "directory" || parsed.type === "file" - ? `file:${parsed.fetchSpec}` + ? pathToFileURL(String(parsed.fetchSpec)).href : parsed.registry && parsed.raw === parsed.name ? `${parsed.name}@latest` : spec @@ -239,6 +247,7 @@ async function prepareCandidate( install: NpmInstaller, readJson: (file: string) => Promise>, satisfies: (version: string, range: string) => boolean, + allowInstall: boolean, ): Promise { let target: string let cache: PluginCacheStatus @@ -251,6 +260,7 @@ async function prepareCandidate( spec: parseNpmPluginSpecifier(declaration.spec, declaration.baseDirectory).installSpec, packageName: declaration.packageName, cacheDirectory, + allowInstall, }) target = typeof installed === "string" ? installed : installed.target cache = typeof installed === "string" ? "installed" : installed.cache @@ -288,21 +298,93 @@ async function prepareCandidate( } } + const contentHash = await pluginContentHash(target, pkg) + return { prepared: { ...declaration, target, entry, cache, + contentHash, package: pkg, }, } } +async function pluginContentHash(target: string, pkg?: PluginPackage): Promise { + try { + const hash = createHash("sha256") + const targetStat = await stat(target) + const sourceRoot = pkg?.directory ?? (targetStat.isDirectory() ? target : path.dirname(target)) + await updateLocalSourceTreeHash(hash, sourceRoot) + if (pkg) { + // npm packages live below an installation root's node_modules. Bind the + // approval to both package-local and resolver-owned lockfiles so a + // dependency graph change cannot reuse a grant for different code. + for (const directory of pluginDigestDirectories(pkg.directory)) { + for (const lockName of ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock"]) { + const lockPath = path.join(directory, lockName) + if (await exists(lockPath)) { + hash.update(lockName) + hash.update(await readFile(lockPath)) + } + } + } + } + return hash.digest("hex") + } catch { + return undefined + } +} + +async function updateLocalSourceTreeHash(hash: ReturnType, root: string) { + const walk = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }) + entries.sort((left, right) => left.name.localeCompare(right.name)) + for (const entry of entries) { + if (entry.isSymbolicLink()) continue + if (entry.isDirectory() && [".git", "node_modules"].includes(entry.name)) continue + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) { + await walk(absolute) + continue + } + if (!entry.isFile()) continue + if (!localExecutableSourceFile(entry.name)) continue + hash.update(path.relative(root, absolute).replaceAll(path.sep, "/")) + hash.update("\0") + hash.update(await readFile(absolute)) + hash.update("\0") + } + } + await walk(root) +} + +function localExecutableSourceFile(name: string) { + return [".cjs", ".js", ".json", ".jsx", ".mjs", ".node", ".ts", ".tsx", ".wasm"].includes( + path.extname(name).toLowerCase(), + ) +} + +function pluginDigestDirectories(packageDirectory: string) { + const directories = [packageDirectory] + let current = path.dirname(packageDirectory) + while (current !== path.dirname(current)) { + if (path.basename(current).toLowerCase() === "node_modules") { + directories.push(path.dirname(current)) + break + } + current = path.dirname(current) + } + return directories +} + async function loadPreparedCandidate(plugin: PreparedPlugin): Promise { let module: Record try { - const imported = await import(plugin.entry) + const cacheKey = plugin.contentHash ?? `${plugin.spec}:${plugin.declarationIndex}` + const imported = await import(`${plugin.entry}?bitfunGeneration=${encodeURIComponent(cacheKey)}`) if (!isRecord(imported)) throw new Error(`Plugin ${plugin.spec} module is empty`) module = imported } catch (error) { @@ -349,6 +431,7 @@ async function normalizeDeclaration( const spec = declaration.spec.trim() const baseDirectory = path.resolve(declaration.baseDirectory ?? defaultBaseDirectory) + const optionsDigest = stableJsonDigest(declaration.options ?? null) const source = pluginSource(spec) if (source === "npm") { const parsed = parseNpmPluginSpecifier(spec, baseDirectory) @@ -357,6 +440,7 @@ async function normalizeDeclaration( spec, resolvedSpec: spec, identity: `npm:${parsed.identity}`, + optionsDigest, source, packageName: parsed.packageName, options: declaration.options, @@ -376,12 +460,21 @@ async function normalizeDeclaration( spec, resolvedSpec, identity: `file:${resolvedSpec}`, + optionsDigest, source, options: declaration.options, baseDirectory, } } +function stableJsonDigest(value: unknown) { + const canonical = JSON.stringify(value, (_key, nested: unknown) => { + if (!isRecord(nested)) return nested + return Object.fromEntries(Object.entries(nested).sort(([left], [right]) => left.localeCompare(right))) + }) + return createHash("sha256").update(canonical).digest("hex") +} + function declarationObject(input: PluginDeclarationInput): PluginDeclaration { if (typeof input === "string") return { spec: input } if (Array.isArray(input)) return { spec: input[0], options: input[1] } diff --git a/src/apps/extension-host/src/main.ts b/src/apps/extension-host/src/main.ts index 931de00043..dc43fa385c 100644 --- a/src/apps/extension-host/src/main.ts +++ b/src/apps/extension-host/src/main.ts @@ -8,6 +8,7 @@ import { prepareBunPlugins } from "./bun-loader" import { BackendMethodSchemas, DEFAULT_MAX_FRAME_BYTES, + HOST_CAPABILITIES, OPENCODE_VERSION, PROTOCOL_VERSION, type BackendMethod, @@ -26,8 +27,13 @@ async function main() { const address = requiredEnvironment("OPENCODE_EXTENSION_HOST_RPC_ADDRESS") const token = requiredEnvironment("OPENCODE_EXTENSION_HOST_RPC_TOKEN") requireLoopbackAddress(address) + let handshakeAccepted = false const peer = await connectRpcPeer(address, { idPrefix: "host", + onRequest(method) { + if (handshakeAccepted) return + throw new ExtensionHostError(-32601, `Method not found: ${method}`) + }, onError(error) { logError("rpc.failure", error, { runtime: "bun" }) }, @@ -71,14 +77,33 @@ async function main() { } let host: ExtensionHost | undefined + registerStreamMethods(peer, registry, owners) + registerHostMethods({ + peer, + host: deferred.promise, + shutdown() { + void peer.flushAndClose().catch((error) => logError("shutdown.rpc_close_failed", error, { runtime: "bun" })) + }, + }) try { - const handshake = BackendMethodSchemas["backend.handshake"].result.parse( - await backend.request("backend.handshake", { - token, - protocolVersion: PROTOCOL_VERSION, - opencodeVersion: OPENCODE_VERSION, - maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, - }), + const handshakeMethod = BackendMethodSchemas["backend.handshake"] + const handshake = handshakeMethod.result.parse( + await peer.request( + "backend.handshake", + handshakeMethod.params.parse({ + token, + protocolVersion: PROTOCOL_VERSION, + opencodeVersion: OPENCODE_VERSION, + maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + capabilities: [...HOST_CAPABILITIES], + }), + { + onResult(value) { + handshakeMethod.result.parse(value) + handshakeAccepted = true + }, + }, + ), ) if (!path.isAbsolute(handshake.cacheDirectory)) { throw new ExtensionHostError(-32001, "backend.handshake returned a relative cacheDirectory", { @@ -100,14 +125,6 @@ async function main() { shell: Bun.$, }) deferred.resolve(host) - registerStreamMethods(peer, registry, owners) - registerHostMethods({ - peer, - host: deferred.promise, - shutdown() { - void peer.flushAndClose().catch((error) => logError("shutdown.rpc_close_failed", error, { runtime: "bun" })) - }, - }) logEvent("startup.ready", { runtime: "bun" }) await peer.closed logEvent("rpc.closed", { runtime: "bun", failed: peer.closeError !== undefined }) diff --git a/src/apps/extension-host/src/protocol.ts b/src/apps/extension-host/src/protocol.ts index 749a82f1c5..18a4efe6ef 100644 --- a/src/apps/extension-host/src/protocol.ts +++ b/src/apps/extension-host/src/protocol.ts @@ -2,7 +2,11 @@ import { z } from "zod" export const PROTOCOL_VERSION = 1 export const OPENCODE_VERSION = "1.17.18" -export const MIN_NEGOTIATED_FRAME_BYTES = 64 * 1024 +export const HOST_CAPABILITIES = [ + "config-contributors-v1", + "config-contributions-v2", + "generation-fencing-v1", +] as const export const DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024 export const MAX_MAX_FRAME_BYTES = 64 * 1024 * 1024 export const MAX_STREAM_CHUNK_BYTES = 64 * 1024 @@ -145,6 +149,19 @@ export const WorkspaceRegistrationSchema = z.object({ name: z.string(), description: z.string(), }) +export const PluginMetaSchema = z.object({ + id: z.string().optional(), + spec: z.string().min(1), + entry: z.string().min(1), + index: z.number().int().nonnegative(), +}) +export const ConfigContributorSchema = z.object({ + plugin: PluginMetaSchema, + outcome: z.enum(["applied", "failed"]), +}) +export const ConfigContributionSchema = ConfigContributorSchema.extend({ + config: JsonObjectSchema, +}) export const AuthSuccessSchema = z.union([ z.object({ type: z.literal("success"), @@ -185,15 +202,27 @@ export const HostMethodSchemas = { plugins: z.array(PluginDeclarationSchema), configurationFingerprint: z.string().min(1).optional(), defaultBaseDirectory: z.string().optional(), + allowInstall: z.boolean().optional(), }), result: z.object({ configurationFingerprint: z.string().min(1).optional(), + reviewDigest: z.string().length(64), + reviewed: z.array(z.object({ + spec: z.string(), + source: z.enum(["file", "npm"]), + identity: z.string().min(1), + canonicalSource: z.string().min(1), + baseDirectory: z.string().min(1), + optionsDigest: z.string().length(64), + })), prepared: z.array(z.object({ spec: z.string(), + identity: z.string().min(1), source: z.enum(["file", "npm"]), target: z.string(), entry: z.string(), cache: z.enum(["hit", "installed", "validated"]), + contentHash: z.string().length(64).optional(), version: z.string().optional(), })), failed: z.array(PluginPrepareFailureSchema), @@ -203,16 +232,24 @@ export const HostMethodSchemas = { "host.instance.open": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), project: JsonValueSchema, config: JsonObjectSchema, directory: z.string(), worktree: z.string(), plugins: z.array(PluginDeclarationSchema), configurationFingerprint: z.string().min(1).optional(), + expectedContentDigests: z.record(z.string(), z.string().length(64)).optional(), + expectedReviewDigest: z.string().length(64).optional(), }), result: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), config: JsonObjectSchema, + configContributors: z.array(ConfigContributorSchema), + configContributions: z.array(ConfigContributionSchema), diagnostics: z.array(DiagnosticSchema), hooks: z.array(z.string()), tools: z.array(ToolRegistrationSchema), @@ -228,11 +265,20 @@ export const HostMethodSchemas = { "host.hook.call": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), + hook: z.string().min(1), + input: JsonValueSchema, + output: JsonValueSchema, + }), + result: z.object({ + instanceID: z.string().min(1).optional(), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), hook: z.string().min(1), input: JsonValueSchema, output: JsonValueSchema, }), - result: z.object({ input: JsonValueSchema, output: JsonValueSchema }), }, "host.event.emit": { params: z.object({ instanceID: z.string().min(1), event: JsonValueSchema }), @@ -241,6 +287,8 @@ export const HostMethodSchemas = { "host.tool.execute": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), registrationID: z.string().min(1), args: JsonValueSchema, @@ -251,11 +299,19 @@ export const HostMethodSchemas = { callID: z.string().optional(), }), }), - result: ToolResultSchema, + result: z.object({ + instanceID: z.string().min(1).optional(), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), + executionID: z.string().min(1), + result: ToolResultSchema, + }), }, "host.tool.cancel": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), reason: z.string().optional(), }), @@ -373,12 +429,14 @@ export const BackendMethodSchemas = { token: z.string().min(1), protocolVersion: z.literal(PROTOCOL_VERSION), opencodeVersion: z.literal(OPENCODE_VERSION), - maxFrameBytes: z.number().int().min(MIN_NEGOTIATED_FRAME_BYTES).max(MAX_MAX_FRAME_BYTES), + maxFrameBytes: z.number().int().positive().max(MAX_MAX_FRAME_BYTES), + capabilities: z.array(z.string().min(1)).optional(), }), result: z.object({ protocolVersion: z.literal(PROTOCOL_VERSION), - maxFrameBytes: z.number().int().min(MIN_NEGOTIATED_FRAME_BYTES).max(MAX_MAX_FRAME_BYTES), + maxFrameBytes: z.number().int().positive().max(MAX_MAX_FRAME_BYTES), cacheDirectory: z.string(), + capabilities: z.array(z.string().min(1)).optional(), }), }, "backend.http.request": { @@ -399,6 +457,8 @@ export const BackendMethodSchemas = { "backend.tool.ask": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), permission: z.string(), patterns: z.array(z.string()), @@ -410,6 +470,8 @@ export const BackendMethodSchemas = { "backend.tool.metadata": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), title: z.string().optional(), metadata: JsonObjectSchema.optional(), diff --git a/src/apps/extension-host/src/rpc.ts b/src/apps/extension-host/src/rpc.ts index 6463bf06de..06d01ffb75 100644 --- a/src/apps/extension-host/src/rpc.ts +++ b/src/apps/extension-host/src/rpc.ts @@ -20,16 +20,19 @@ export type RpcPeerOptions = { maxFrameBytes?: number onEof?: (error?: Error) => void | Promise onError?: (error: Error) => void + onRequest?: (method: string) => void } export type RpcRequestOptions = { signal?: AbortSignal + onResult?: (value: unknown) => void } type PendingRequest = { resolve(value: unknown): void reject(error: Error): void cleanup(): void + onResult?: (value: unknown) => void } export class RpcError extends Error { @@ -66,6 +69,7 @@ export class RpcPeer { readonly #pending = new Map() readonly #onEof?: RpcPeerOptions["onEof"] readonly #onError?: RpcPeerOptions["onError"] + readonly #onRequest?: RpcPeerOptions["onRequest"] readonly #resolveClosed: () => void #buffer = new Uint8Array() #sequence = 0 @@ -81,6 +85,7 @@ export class RpcPeer { this.#maxFrameBytes = validateMaxFrameBytes(options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES) this.#onEof = options.onEof this.#onError = options.onError + this.#onRequest = options.onRequest const deferred = Promise.withResolvers() this.closed = deferred.promise this.#resolveClosed = deferred.resolve @@ -117,7 +122,12 @@ export class RpcPeer { deferred.reject(abortError(options.signal?.reason)) } const cleanup = () => options.signal?.removeEventListener("abort", abort) - this.#pending.set(id, { resolve: deferred.resolve, reject: deferred.reject, cleanup }) + this.#pending.set(id, { + resolve: deferred.resolve, + reject: deferred.reject, + cleanup, + ...(options.onResult ? { onResult: options.onResult } : {}), + }) options.signal?.addEventListener("abort", abort, { once: true }) try { @@ -223,10 +233,23 @@ export class RpcPeer { pending.reject(new RpcError(message.error.code, message.error.message, message.error.data)) return } + try { + pending.onResult?.(message.result) + } catch (error) { + pending.reject(asError(error)) + return + } pending.resolve(message.result) } async #dispatch(method: string, params: unknown, id?: string) { + try { + this.#onRequest?.(method) + } catch (error) { + if (id) await this.#sendError(id, rpcErrorObject(error)) + else this.#reportError(asError(error)) + return + } const handler = this.#handlers.get(method) if (!handler) { if (id) await this.#sendError(id, { code: -32601, message: `Method not found: ${method}` }) diff --git a/src/apps/extension-host/src/service.ts b/src/apps/extension-host/src/service.ts index 6a4e443df2..445f25a415 100644 --- a/src/apps/extension-host/src/service.ts +++ b/src/apps/extension-host/src/service.ts @@ -39,7 +39,14 @@ export function registerHostMethods(input: { register("host.hook.call", async (params) => (await host()).callHook( (() => { - const value = params as { instanceID: string; hook: string; input: WireValue; output: WireValue } + const value = params as { + instanceID: string + generationKey?: string + revision?: string + hook: string + input: WireValue + output: WireValue + } return { ...value, name: value.hook } })(), ), diff --git a/src/apps/extension-host/test/fixtures/gateway/injected.ts b/src/apps/extension-host/test/fixtures/gateway/injected.ts index 0a423ddc7d..a6ca47aca8 100644 --- a/src/apps/extension-host/test/fixtures/gateway/injected.ts +++ b/src/apps/extension-host/test/fixtures/gateway/injected.ts @@ -3,7 +3,9 @@ import type { PluginModule } from "@opencode-ai/plugin" const server: PluginModule["server"] = async (input) => { const project = await input.client.project.current() const raw = await fetch(new URL("/raw?fixture=1", input.serverUrl)).then((response) => response.text()) - const shell = (await input.$`printf injected-shell`.text()).trim() + // `echo` is provided by Bun Shell on every supported platform; `printf` + // is not available in the Windows implementation. + const shell = (await input.$`echo injected-shell`.text()).trim() input.experimental_workspace.register("fixture-remote", { name: "Fixture remote", diff --git a/src/apps/extension-host/test/fixtures/runtime/full.js b/src/apps/extension-host/test/fixtures/runtime/full.js index e6a975dc95..d574a1c380 100644 --- a/src/apps/extension-host/test/fixtures/runtime/full.js +++ b/src/apps/extension-host/test/fixtures/runtime/full.js @@ -42,6 +42,10 @@ export default { hookInput.trace.push("full") output.trace.push("full") }, + "tool.execute.before": async () => { + await record(options.hookStartMarker, "started") + if (options.hookDelayMs) await Bun.sleep(options.hookDelayMs) + }, dispose: async () => { await record(options.disposeMarker, `full:${run}`) }, @@ -61,10 +65,14 @@ export default { metadata: { value: args.value }, }) if (args.waitForAbort) { - await new Promise((resolve, reject) => { - if (context.abort.aborted) return reject(new Error("fixture aborted")) - context.abort.addEventListener("abort", () => reject(new Error("fixture aborted")), { once: true }) - }) + if (options.ignoreAbortMs) { + await Bun.sleep(options.ignoreAbortMs) + } else { + await new Promise((resolve, reject) => { + if (context.abort.aborted) return reject(new Error("fixture aborted")) + context.abort.addEventListener("abort", () => reject(new Error("fixture aborted")), { once: true }) + }) + } } return { title: `echo:${args.value}`, diff --git a/src/apps/extension-host/test/fixtures/runtime/opening.js b/src/apps/extension-host/test/fixtures/runtime/opening.js new file mode 100644 index 0000000000..c43c5fb534 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/opening.js @@ -0,0 +1,14 @@ +import { writeFileSync } from "node:fs" + +export default { + id: "fixture.opening", + server: async (_input, options = {}) => { + writeFileSync(options.started, "started") + await new Promise((resolve) => setTimeout(resolve, 30)) + return { + dispose() { + writeFileSync(options.disposed, "disposed") + }, + } + }, +} diff --git a/src/apps/extension-host/test/helpers/process-host.ts b/src/apps/extension-host/test/helpers/process-host.ts index 16b4ddd96e..9fc9bc607f 100644 --- a/src/apps/extension-host/test/helpers/process-host.ts +++ b/src/apps/extension-host/test/helpers/process-host.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises" import path from "node:path" import { tmpdir } from "node:os" -import { DEFAULT_MAX_FRAME_BYTES, OPENCODE_VERSION, PROTOCOL_VERSION } from "../../src/protocol" +import { DEFAULT_MAX_FRAME_BYTES, HOST_CAPABILITIES, OPENCODE_VERSION, PROTOCOL_VERSION } from "../../src/protocol" import { RpcPeer } from "../../src/rpc" type Handshake = { @@ -9,6 +9,7 @@ type Handshake = { protocolVersion: number opencodeVersion: string maxFrameBytes: number + capabilities: string[] } export async function launchExtensionHost( @@ -139,6 +140,7 @@ export function expectedHandshake(token = "test-rpc-token") { protocolVersion: PROTOCOL_VERSION, opencodeVersion: OPENCODE_VERSION, maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + capabilities: [...HOST_CAPABILITIES], } } diff --git a/src/apps/extension-host/test/host.test.ts b/src/apps/extension-host/test/host.test.ts index 54cd370281..79e7f56bb6 100644 --- a/src/apps/extension-host/test/host.test.ts +++ b/src/apps/extension-host/test/host.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdtemp, mkdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" import path from "node:path" import type { RpcConnection, StreamBridge, StreamDescriptor } from "../src/backend" import { ExtensionHost } from "../src/host" @@ -32,7 +33,7 @@ describe("ExtensionHost lifecycle and hooks", () => { const configurationFingerprint = "fixture-prewarm" const plugins = [{ spec: plugin }] - const preparing = harness.host.prepare({ plugins, configurationFingerprint }) + const preparing = harness.host.prepare({ plugins, configurationFingerprint, defaultBaseDirectory: directory }) const opening = harness.host.open({ instanceID: "prewarm", project: {}, @@ -52,7 +53,97 @@ describe("ExtensionHost lifecycle and hooks", () => { expect(prepareCalls).toBe(1) }) - test("isolates config and dispose failures while preserving shared sequential mutations", async () => { + test("rejects a declaration that changes after activation review", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "review-fence") + const plugin = path.join(harness.root, "review-fence.ts") + const plugins = [{ spec: plugin, options: { permission: "ask" } }] + await Bun.write(plugin, 'export default { id: "fixture.review-fence", server: async () => ({}) }\n') + + const prepared = await harness.host.prepare({ plugins, configurationFingerprint: "review-fence" }) + expect(prepared.reviewDigest).toMatch(/^[0-9a-f]{64}$/) + + await expect( + harness.host.open({ + instanceID: "review-fence", + project: {}, + directory, + worktree: directory, + config: {}, + plugins: [{ spec: plugin, options: { permission: "auto" } }], + configurationFingerprint: "review-fence", + expectedReviewDigest: prepared.reviewDigest, + }), + ).rejects.toMatchObject({ data: { kind: "prepared_review_changed" } }) + }) + + test("recomputes prepared content before opening the same declaration", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "content-fence") + const plugin = path.join(harness.root, "content-fence.ts") + const plugins = [{ spec: plugin }] + await Bun.write(plugin, 'export default { id: "fixture.content-fence-v1" }\n') + + const prepared = await harness.host.prepare({ + plugins, + configurationFingerprint: "content-fence", + defaultBaseDirectory: directory, + }) + const expectedContentDigests = Object.fromEntries( + prepared.prepared.map((entry) => [entry.identity, entry.contentHash!]), + ) + await Bun.write(plugin, 'export default { id: "fixture.content-fence-v2" }\n') + + await expect( + harness.host.open({ + instanceID: "content-fence", + project: {}, + directory, + worktree: directory, + config: {}, + plugins, + configurationFingerprint: "content-fence", + expectedReviewDigest: prepared.reviewDigest, + expectedContentDigests, + }), + ).rejects.toMatchObject({ data: { kind: "prepared_review_changed" } }) + }) + + test("uses the normalized plugin graph when duplicate declarations are reviewed", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "review-dedupe") + const plugin = path.join(harness.root, "review-dedupe.ts") + await Bun.write(plugin, 'export default { id: "fixture.review-dedupe", server: async () => ({}) }\n') + const plugins = [ + { spec: plugin, options: { selected: false } }, + { spec: plugin, options: { selected: true } }, + ] + + const prepared = await harness.host.prepare({ + plugins, + configurationFingerprint: "review-dedupe", + defaultBaseDirectory: directory, + }) + expect(prepared.reviewed).toHaveLength(1) + expect(prepared.prepared).toHaveLength(1) + + const opened = await harness.host.open({ + instanceID: "review-dedupe", + project: {}, + directory, + worktree: directory, + config: {}, + plugins, + configurationFingerprint: "review-dedupe", + expectedReviewDigest: prepared.reviewDigest, + expectedContentDigests: Object.fromEntries( + prepared.prepared.map((entry) => [entry.identity, entry.contentHash!]), + ), + }) + expect(opened.instanceID).toBe("review-dedupe") + }) + + test("rolls back failed config mutations while preserving successful sequential mutations", async () => { const harness = await createHarness() const directory = await projectDirectory(harness.root, "project") const disposeMarker = path.join(harness.root, "dispose.txt") @@ -71,7 +162,19 @@ describe("ExtensionHost lifecycle and hooks", () => { ], }) - expect(opened.config).toMatchObject({ order: ["a", "b"] }) + expect(opened.config).toMatchObject({ order: ["b"] }) + expect(opened.configContributors).toHaveLength(2) + expect(opened.configContributions).toHaveLength(2) + expect(opened.configContributions[0]).toMatchObject({ + plugin: expect.objectContaining({ id: "fixture.sequence-a" }), + outcome: "failed", + config: { order: [] }, + }) + expect(opened.configContributions[1]).toMatchObject({ + plugin: expect.objectContaining({ id: "fixture.sequence-b" }), + outcome: "applied", + config: { order: ["b"] }, + }) expect(opened.diagnostics).toHaveLength(1) expect(opened.diagnostics[0]).toMatchObject({ code: "runtime", method: "runtime" }) expect(opened.hooks).toContain("chat.message") @@ -82,7 +185,14 @@ describe("ExtensionHost lifecycle and hooks", () => { input: { order: [] }, output: { order: [] }, }) - expect(called).toEqual({ input: { order: ["a", "b"] }, output: { order: ["a", "b"] } }) + expect(called).toEqual({ + instanceID: "lifecycle", + generationKey: undefined, + revision: undefined, + hook: "chat.message", + input: { order: ["a", "b"] }, + output: { order: ["a", "b"] }, + }) expect(await harness.host.close({ instanceID: "lifecycle" })).toEqual({ closed: true }) expect(await Bun.file(disposeMarker).text()).toBe("a\nb\n") @@ -167,10 +277,16 @@ describe("ExtensionHost tools", () => { context: { sessionID: "session", messageID: "message", agent: "agent", callID: "call" }, }) expect(result).toEqual({ - title: "echo:hello", - output: `hello:${directory}:${directory}`, - metadata: { sessionID: "session", callID: "call" }, - attachments: [{ type: "file", mime: "text/plain", url: "data:text/plain,fixture", filename: "fixture.txt" }], + instanceID: "tools", + generationKey: undefined, + revision: undefined, + executionID: "execute-1", + result: { + title: "echo:hello", + output: `hello:${directory}:${directory}`, + metadata: { sessionID: "session", callID: "call" }, + attachments: [{ type: "file", mime: "text/plain", url: "data:text/plain,fixture", filename: "fixture.txt" }], + }, }) expect(harness.rpc.notifications).toContainEqual({ method: "backend.tool.metadata", @@ -200,9 +316,13 @@ describe("ExtensionHost tools", () => { args: { value: "wait", waitForAbort: true }, context: { sessionID: "session", messageID: "message", agent: "agent" }, }) + const cancelledResult = pending.then( + () => undefined, + (error) => error, + ) await waitFor(() => harness.rpc.requests.some((request) => request.params.executionID === "execute-2")) - expect(harness.host.cancelTool({ instanceID: "tools", executionID: "execute-2" })).toEqual({ cancelled: true }) - await expect(pending).rejects.toMatchObject({ + await expect(harness.host.cancelTool({ instanceID: "tools", executionID: "execute-2" })).resolves.toEqual({ cancelled: true }) + await expect(cancelledResult).resolves.toMatchObject({ code: -32003, data: expect.objectContaining({ operation: "tool:fixture.echo" }), }) @@ -441,21 +561,9 @@ describe("ExtensionHost instance isolation", () => { const harness = await createHarness() const firstDirectory = await projectDirectory(harness.root, "first-race") const secondDirectory = await projectDirectory(harness.root, "second-race") - const plugin = path.join(harness.root, "opening-plugin.ts") + const plugin = path.join(fixtures, "opening.js") const started = path.join(harness.root, "started.txt") const disposed = path.join(harness.root, "disposed.txt") - await Bun.write( - plugin, - `export default { - id: "fixture.opening", - server: async (_input, options) => { - await Bun.write(options.started, "started") - await Bun.sleep(30) - return { async dispose() { await Bun.write(options.disposed, "disposed") } } - }, - }\n`, - ) - const cancelledBeforeReady = harness.host.open({ instanceID: "cancel-before-ready", project: {}, @@ -479,6 +587,10 @@ describe("ExtensionHost instance isolation", () => { config: {}, plugins: [{ spec: plugin, options: { started, disposed } }], }) + const openingResult = opening.then( + () => undefined, + (error) => error, + ) await expect( harness.host.open({ instanceID: "opening", @@ -492,7 +604,7 @@ describe("ExtensionHost instance isolation", () => { await waitFor(() => Bun.file(started).exists()) const shutdown = harness.host.shutdown() - await expect(opening).rejects.toMatchObject({ code: -32004 }) + expect(await openingResult).toMatchObject({ code: -32004 }) await shutdown expect(await Bun.file(disposed).text()).toBe("disposed") await expect( @@ -527,9 +639,94 @@ describe("ExtensionHost instance isolation", () => { harness.streams.failCancelAll = true expect(await harness.host.close({ instanceID: "cancel-failure" })).toEqual({ closed: true }) - expect(await Bun.file(marker).text()).toBe("full:1\n") + expect(await Bun.file(marker).text()).toMatch(/^full:\d+\n$/) expect(await harness.host.close({ instanceID: "cancel-failure" })).toEqual({ closed: false }) }) + + test("does not confirm cancellation when a tool ignores abort and completes", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "ignore-abort") + harness.rpc.onRequest("backend.tool.ask", async () => ({})) + const opened = await openFull(harness, "ignore-abort", directory, { ignoreAbortMs: 50 }) + const registration = opened.tools.find((tool) => tool.id === "fixture.echo")! + const execution = harness.host.executeTool({ + instanceID: "ignore-abort", + registrationID: registration.registrationID, + executionID: "ignore-abort-execution", + args: { value: "slow", waitForAbort: true }, + context: { sessionID: "session", messageID: "message", agent: "agent" }, + }) + await waitFor(() => + harness.rpc.requests.some((request) => request.params.executionID === "ignore-abort-execution"), + ) + + await expect( + harness.host.cancelTool({ instanceID: "ignore-abort", executionID: "ignore-abort-execution" }), + ).resolves.toEqual({ cancelled: false }) + await expect(execution).resolves.toMatchObject({ + result: { output: "slow:" + directory + ":" + directory }, + }) + }) + + test("poisons the Host when an active tool does not drain before close", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "drain-timeout") + const opened = await openFull(harness, "drain-timeout", directory, { ignoreAbortMs: 2_500 }) + const registration = opened.tools.find((tool) => tool.id === "fixture.echo")! + const execution = harness.host.executeTool({ + instanceID: "drain-timeout", + registrationID: registration.registrationID, + executionID: "drain-timeout-execution", + args: { value: "slow", waitForAbort: true }, + context: { sessionID: "session", messageID: "message", agent: "agent" }, + }) + await waitFor(() => harness.rpc.requests.some((request) => request.params.executionID === "drain-timeout-execution")) + + await expect(harness.host.close({ instanceID: "drain-timeout" })).rejects.toThrow("Plugin tool drain timed out") + await expect( + harness.host.open({ + instanceID: "replacement", + project: {}, + directory, + worktree: directory, + config: {}, + plugins: [], + }), + ).rejects.toMatchObject({ data: { kind: "host_shutting_down" } }) + await execution.catch(() => undefined) + }) + + test("poisons the Host when an active hook does not drain before close", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "hook-drain-timeout") + const marker = path.join(harness.root, "hook-started.txt") + await openFull(harness, "hook-drain-timeout", directory, { + hookDelayMs: 2_500, + hookStartMarker: marker, + }) + const hook = harness.host.callHook({ + instanceID: "hook-drain-timeout", + name: "tool.execute.before", + input: { tool: "fixture.echo" }, + output: { args: { value: "slow" } }, + }) + await waitFor(() => Bun.file(marker).exists()) + + await expect(harness.host.close({ instanceID: "hook-drain-timeout" })).rejects.toThrow( + "Plugin hook drain timed out", + ) + await expect( + harness.host.open({ + instanceID: "replacement-after-hook-timeout", + project: {}, + directory, + worktree: directory, + config: {}, + plugins: [], + }), + ).rejects.toMatchObject({ code: -32004 }) + await hook + }) }) class FakeRpc implements RpcConnection { @@ -641,7 +838,7 @@ async function projectDirectory(root: string, name: string) { } async function temporaryDirectory() { - const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-runtime-")) + const directory = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-runtime-")) temporaryDirectories.push(directory) return directory } diff --git a/src/apps/extension-host/test/loader.test.ts b/src/apps/extension-host/test/loader.test.ts index b80bd4ad13..3767908daf 100644 --- a/src/apps/extension-host/test/loader.test.ts +++ b/src/apps/extension-host/test/loader.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises" +import { tmpdir } from "node:os" import path from "node:path" import { pathToFileURL } from "node:url" import { z } from "zod" @@ -40,9 +41,31 @@ describe("plugin loader", () => { expect(result.diagnostics).toEqual([]) expect(result.declarations).toHaveLength(1) expect(result.declarations[0]?.options).toEqual({ order: 2 }) + expect(result.declarations[0]?.optionsDigest).toMatch(/^[0-9a-f]{64}$/) expect(result.declarations[0]?.resolvedSpec).toBe(pathToFileURL(await realpath(plugin)).href) }) + test("binds normalized source and declaration options into stable review facts", async () => { + const directory = await temporaryDirectory() + const plugin = path.join(directory, "plugin.ts") + await Bun.write(plugin, 'export default { id: "fixture.review", server: async () => ({}) }\n') + + const first = await normalizePluginDeclarations([ + { spec: "./plugin.ts", baseDirectory: directory, options: { nested: { right: 2, left: 1 } } }, + ]) + const reordered = await normalizePluginDeclarations([ + { spec: pathToFileURL(plugin).href, baseDirectory: directory, options: { nested: { left: 1, right: 2 } } }, + ]) + const changed = await normalizePluginDeclarations([ + { spec: "./plugin.ts", baseDirectory: directory, options: { nested: { left: 1, right: 3 } } }, + ]) + + expect(first.declarations[0]?.identity).toBe(reordered.declarations[0]?.identity) + expect(first.declarations[0]?.resolvedSpec).toBe(reordered.declarations[0]?.resolvedSpec) + expect(first.declarations[0]?.optionsDigest).toBe(reordered.declarations[0]?.optionsDigest) + expect(first.declarations[0]?.optionsDigest).not.toBe(changed.declarations[0]?.optionsDigest) + }) + test("prefers the default object-form plugin and exposes entrypoints without executing them", async () => { const result = await loadPlugins({ declarations: [path.join(fixtures, "preferred.ts")], @@ -76,6 +99,25 @@ describe("plugin loader", () => { expect(await Bun.file(marker).exists()).toBe(true) }) + test("changes the content digest when a local plugin dependency changes", async () => { + const directory = await temporaryDirectory() + const plugin = path.join(directory, "plugin.ts") + const dependency = path.join(directory, "dependency.ts") + await Bun.write(dependency, 'export const id = "first"\n') + await Bun.write( + plugin, + 'import { id } from "./dependency"\nexport default { id, server: async () => ({}) }\n', + ) + + const first = await preparePlugins({ declarations: [plugin], cacheDirectory: directory }) + await Bun.write(dependency, 'export const id = "second"\n') + const second = await preparePlugins({ declarations: [plugin], cacheDirectory: directory }) + + expect(first.prepared[0]?.contentHash).toMatch(/^[0-9a-f]{64}$/) + expect(second.prepared[0]?.contentHash).toMatch(/^[0-9a-f]{64}$/) + expect(second.prepared[0]?.contentHash).not.toBe(first.prepared[0]?.contentHash) + }) + test("deduplicates legacy exports by exported value identity", async () => { const module = await import(pathToFileURL(path.join(fixtures, "legacy.ts")).href) const entrypoints = extractServerEntrypoints({ @@ -230,7 +272,7 @@ describe("plugin loader", () => { path.join(outside, "server.ts"), 'export default { id: "fixture.escape", server: async () => ({}) }\n', ) - await symlink(outside, path.join(plugin, "escape"), "dir") + await symlink(outside, path.join(plugin, "escape"), process.platform === "win32" ? "junction" : "dir") const result = await loadPlugins({ declarations: [plugin], @@ -288,11 +330,14 @@ describe("plugin loader", () => { type: "git", }) expect(parseNpmPluginSpecifier("file:./plugin.tgz", "/tmp/extension-host-base").installSpec).toBe( - "file:/tmp/extension-host-base/plugin.tgz", + pathToFileURL(path.resolve("/tmp/extension-host-base/plugin.tgz")).href, ) }) - test("installs npm packages with lifecycle scripts disabled", async () => { + // Bun 1.3.x cannot install local file dependencies reliably on Windows + // (oven-sh/bun#13379). File plugin declarations use the direct validated + // loader path in production; this integration case remains covered on Unix. + test.skipIf(process.platform === "win32")("installs npm packages with lifecycle scripts disabled", async () => { const directory = await temporaryDirectory() const source = path.join(directory, "source") const marker = path.join(directory, "postinstall.txt") @@ -309,7 +354,7 @@ describe("plugin loader", () => { await Bun.write(path.join(source, "index.js"), "export default { server: async () => ({}) }\n") const target = await installNpmPlugin({ - spec: `file:${source}`, + spec: pathToFileURL(source).href, packageName: "fixture-install", cacheDirectory: path.join(directory, "cache"), }) @@ -319,6 +364,31 @@ describe("plugin loader", () => { expect(await Bun.file(marker).exists()).toBe(false) }) + test("review preparation forwards the non-installing contract to npm resolution", async () => { + const directory = await temporaryDirectory() + let allowInstall: boolean | undefined + const result = await preparePlugins({ + declarations: ["fixture-review@1.0.0"], + cacheDirectory: path.join(directory, "cache"), + allowInstall: false, + install: async (input) => { + allowInstall = input.allowInstall + throw new Error("activation review requires installation approval") + }, + }) + + expect(allowInstall).toBe(false) + expect(result.reviewed[0]).toMatchObject({ + spec: "fixture-review@1.0.0", + source: "npm", + identity: "npm:fixture-review", + }) + expect(result.reviewed[0]?.optionsDigest).toMatch(/^[0-9a-f]{64}$/) + expect(result.prepared).toEqual([]) + expect(result.diagnostics[0]?.stage).toBe("install") + expect(result.diagnostics[0]?.message).toContain("approval") + }) + test("reuses an installed npm package directory without reinstalling it", async () => { const directory = await temporaryDirectory() const cacheDirectory = path.join(directory, "cache") @@ -420,7 +490,7 @@ describe("plugin tool schemas", () => { }) async function temporaryDirectory() { - const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-")) + const directory = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-")) temporaryDirectories.push(directory) return directory } diff --git a/src/apps/extension-host/test/process.test.ts b/src/apps/extension-host/test/process.test.ts index 9fb7fa8706..fdecf09b96 100644 --- a/src/apps/extension-host/test/process.test.ts +++ b/src/apps/extension-host/test/process.test.ts @@ -111,9 +111,20 @@ describe("extension host process boundary", () => { const harness = await launchExtensionHost({ handshakeGate: gate.promise }) running.add(harness) - await expect(harness.peer.request("host.stream.cancel", {})).rejects.toMatchObject({ code: -32601 }) + const earlyRequest = harness.peer.request("host.stream.cancel", {}) + const outcome = await Promise.race([ + earlyRequest.then( + (value) => ({ kind: "resolved" as const, value }), + (error) => ({ kind: "rejected" as const, error }), + ), + Bun.sleep(1_000).then(() => ({ kind: "timeout" as const })), + ]) gate.resolve() - expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + if (outcome.kind === "timeout") await earlyRequest.catch(() => {}) + expect(outcome).toMatchObject({ kind: "rejected", error: { code: -32601 } }) + expect( + await requestAfterHandshakeReply(() => harness.peer.request<{ closed: boolean }>("host.shutdown", {})), + ).toEqual({ closed: true }) expect(await harness.waitForExit()).toBe(0) }) @@ -245,6 +256,18 @@ describe("extension host process boundary", () => { }, 10_000) }) +async function requestAfterHandshakeReply(request: () => Promise) { + const deadline = performance.now() + 1_000 + for (;;) { + try { + return await request() + } catch (error) { + if (!(error instanceof RpcError) || error.code !== -32601 || performance.now() >= deadline) throw error + await Bun.sleep(5) + } + } +} + type OpenResult = { config: Record } diff --git a/src/apps/server/Cargo.toml b/src/apps/server/Cargo.toml index 8e9fd4ad16..d8368eb7c6 100644 --- a/src/apps/server/Cargo.toml +++ b/src/apps/server/Cargo.toml @@ -26,7 +26,7 @@ axum = { workspace = true, features = ["ws"] } tower-http = { workspace = true, features = ["cors"] } # Inherited from workspace -tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync"] } +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/src/apps/server/README.md b/src/apps/server/README.md index 347238dd95..9deba3e8cd 100644 --- a/src/apps/server/README.md +++ b/src/apps/server/README.md @@ -12,3 +12,9 @@ If you are looking for **Remote Connect self-hosted relay deployment**, use: - [deploy.sh](../relay-server/deploy.sh) `src/apps/server` and `src/apps/relay-server` are different components. `src/apps/server` is the main web app backend, while `src/apps/relay-server` is the relay service used by Remote Connect. + +When exercising configured OpenCode plugins, use `pnpm run server:dev` or +`pnpm run server:build`. These commands prepare the extension Host first; a +compatible `bun` command (or `BITFUN_BUN_COMMAND`) is required at runtime. The +release build stages `resources/ext-host` beside the server binary; deploy that +directory with the binary. diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index f836c503f8..b68409ce17 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -160,6 +160,36 @@ pub(crate) async fn initialize(workspace: Option) -> anyhow::Result Result<()> { ); let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; + let server = axum::serve(listener, app).with_graceful_shutdown(async { + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::error!(error = %error, "Failed to listen for Server shutdown signal"); + } + }); + let serve_result = server.await; + let shutdown_result = bitfun_core::plugin_host::shutdown_configured_plugin_host().await; + serve_result?; + shutdown_result?; Ok(()) } diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index 5dd337d4a9..9915906c5c 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -52,6 +52,8 @@ pub struct RuntimeUserAnswersRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RuntimeAgentModeSummary { pub id: String, + #[serde(default)] + pub route_key: String, pub description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 1e07f64e9c..87879bf4f2 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -87,6 +87,7 @@ fn protocol_round_trips_read_only_main_agent_catalog() { let result = RuntimeIpcOperationResult::AgentModes { modes: vec![RuntimeAgentModeSummary { id: "review".to_string(), + route_key: "opencode:review".to_string(), description: "Review the current workspace".to_string(), model_id: Some("provider/model".to_string()), is_external: true, @@ -330,6 +331,7 @@ fn protocol_round_trips_the_reviewed_session_mode_operation() { request: AgentSessionModeUpdateRequest { session_id: "session-1".to_string(), mode_id: "ask".to_string(), + agent_route_key: None, }, }; diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index 5c0703df53..f61ee28349 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -670,6 +670,7 @@ fn create_operation(workspace: &Path, name: &str) -> RuntimeIpcOperation { request: AgentSessionCreateRequest { session_name: name.to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace.to_string_lossy().to_string()), project_workspace_path: None, execution_target: None, @@ -752,6 +753,7 @@ fn update_mode_operation(session_id: &str, mode_id: &str) -> RuntimeIpcOperation request: AgentSessionModeUpdateRequest { session_id: session_id.to_string(), mode_id: mode_id.to_string(), + agent_route_key: None, }, } } diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index 1750584bb9..d311bf029d 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -5,8 +5,9 @@ The current crate owns OpenCode user Instruction path/config precedence, the static OpenCode source preview used by the existing managed-package path, the OpenCode-specific implementations of command, standalone-tool, subagent, and MCP provider contracts, the bounded projection of -configured local Skill roots, and runtime-free mapping -of caller-normalized tool Hook descriptors. It preserves OpenCode source +configured local Skill roots, the private merged configuration snapshot passed +to the managed Plugin Host's Config Hook, and runtime-free mapping of +caller-normalized static-preview Hook descriptors. 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 @@ -39,11 +40,11 @@ Product-source boundary: - `ExternalSourceControlPlane` owns candidate versions and atomic provider replacement. This adapter supplies OpenCode-qualified source identity/order and watch roots through narrow provider contracts; the reusable file-watch service - supplies change facts. Config modules provide normalized config snapshots; the services - implementation behind `ScriptToolRuntime` owns dependencies, workers, process trees, - and physical health; `PluginRuntimeClient` currently owns request reliability, - diagnostics and fault status while consuming lifecycle facts from their responsible modules; - capability modules register contributions. + supplies change facts. For the current package-plugin path, Core owns logical + workspace generations and capability publication while this adapter owns the + typed Host wire/connection and uses services process-tree primitives. The legacy + managed-package path keeps request reliability in `PluginRuntimeClient`, and + standalone workers remain under `ScriptToolRuntime`; do not merge these lifecycle facts. - Effective policy and safe-start mode must be recomputed before third-party module import from the source, plugin identity, actual execution domain/user, product/organization policy bounds, credential scope, and environment scope. @@ -74,9 +75,11 @@ Product-source boundary: `OPENCODE_CONFIG`, project files, ordered config directories, and `OPENCODE_CONFIG_CONTENT`. Capability providers consume only the plan items they understand and retain their field-specific merge and validation rules; - do not introduce a generic merged OpenCode config contract. Inline content is - bounded, uses a redacted virtual source identity, has no watch root, and may - resolve relative paths only from an explicit workspace context. + do not introduce a public generic merged OpenCode config contract. The managed + Plugin Host may consume an adapter-private, bounded full-object merge for its + Config Hook, including unknown OpenCode fields. Inline content is bounded, + uses a redacted virtual source identity, has no watch root, and may resolve + relative paths only from an explicit workspace context. - Current source inspection recognizes only the tested declarative subset. The adapter may reuse the workspace-pinned parse-only OXC profile for syntax-safe static projection, but it is not a general JavaScript/TypeScript semantic analyzer or @@ -93,7 +96,9 @@ Product-source boundary: the adapter owns their OpenCode meaning. Mapping may emit static declarations and diagnostics with incomplete safety. Parse failures must remain explicit diagnostics, while event payload types must not be treated as Hook properties. - The adapter must not load handlers, dispatch Hooks, or imply executable support. + This static-preview path does not load or dispatch handlers. Executable callback + support is a separate managed Plugin Host path using the typed gateway and Core + lifecycle owner. - The reviewed product assembly entrypoint selects and constructs the compiled OpenCode adapter/provider. External-source providers and configured Skill-root facts are projected through `bitfun-core/external_sources`; managed-package diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index c22c160ab3..6c7cdfa5f3 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -29,5 +29,8 @@ pub use reference_source::{ pub use skill_source::{ OpenCodeConfiguredSkillRoot, OpenCodeSkillRootProvider, OpenCodeSkillRootProviderOptions, }; -pub use source_adapter::load_opencode_package_adapter; +pub use source_adapter::{ + load_opencode_config_snapshot, load_opencode_package_adapter, OpenCodeConfigSnapshot, + OpenCodeConfigSnapshotError, +}; pub use tool_source::{OpenCodeToolProvider, OpenCodeToolProviderOptions}; diff --git a/src/crates/adapters/opencode-adapter/src/source_adapter.rs b/src/crates/adapters/opencode-adapter/src/source_adapter.rs index 3201e20514..ae3c3b4c50 100644 --- a/src/crates/adapters/opencode-adapter/src/source_adapter.rs +++ b/src/crates/adapters/opencode-adapter/src/source_adapter.rs @@ -7,6 +7,10 @@ use crate::hook_contributions::{ map_hook_contributions, OpenCodeHookDescriptor, OPENCODE_PLUGIN_PROVIDER_ID, }; +use crate::local_source_paths::{ + local_source_plan, LocalConfigDocument, LocalConfigDocumentKind, LocalSourcePlanItem, + OpenCodeLocalConfigOptions, +}; use async_trait::async_trait; use bitfun_plugin_runtime_client::PluginRuntimeAdapter; use bitfun_product_domains::external_hook_contributions::{ @@ -38,9 +42,14 @@ use oxc_parse::{ parser::Parser, span::SourceType, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; -use std::{collections::HashSet, path::Path, sync::Arc}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{Arc, OnceLock}, +}; const OPENCODE_ADAPTER_ID: &str = "opencode-compatible"; const OPENCODE_CONFIG_SCHEMA: &str = "https://opencode.ai/config.json"; @@ -57,6 +66,7 @@ const MAX_CUSTOM_TOOL_ID_BYTES: usize = 64; const MAX_NPM_PLUGINS: usize = 128; const MAX_NPM_PLUGIN_NAME_BYTES: usize = 256; const MAX_NPM_PLUGIN_METADATA_BYTES: usize = 16 * 1024; +const MAX_CONFIG_SNAPSHOT_FILE_BYTES: usize = 1024 * 1024; // Frozen from the @opencode-ai/plugin Hooks interface. `tool` is handled by // the existing custom-tool projection and event-bus event types belong under @@ -1696,6 +1706,345 @@ impl OpenCodeSourceProjection { } } +/// Complete merged OpenCode configuration passed to the extension host when a +/// workspace plugin instance opens. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(transparent)] +pub struct OpenCodeConfigSnapshot { + pub config: Map, +} + +#[derive(Debug, thiserror::Error)] +pub enum OpenCodeConfigSnapshotError { + #[error("failed to read OpenCode config {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("invalid OpenCode config {path}: {message}")] + Invalid { path: PathBuf, message: String }, +} + +/// Load the complete local OpenCode configuration for one workspace using the +/// same source plan as the runtime-free compatibility providers. Missing +/// sources produce an empty object; unreadable or invalid declared sources +/// fail activation instead of silently sending a partial configuration. +pub fn load_opencode_config_snapshot( + workspace: &Path, +) -> Result { + load_opencode_config_snapshot_with_options(workspace, &OpenCodeLocalConfigOptions::default()) +} + +fn load_opencode_config_snapshot_with_options( + workspace: &Path, + options: &OpenCodeLocalConfigOptions, +) -> Result { + let mut config = Map::new(); + let mut user_config = Map::new(); + for item in local_source_plan(options, Some(workspace), None) { + let LocalSourcePlanItem::Config(document) = item else { + continue; + }; + let path = PathBuf::from(document.location()); + let content = match document.read_bounded(MAX_CONFIG_SNAPSHOT_FILE_BYTES) { + Ok(bitfun_services_core::bounded_fs::BoundedTextRead::Content(content)) => content, + Ok(bitfun_services_core::bounded_fs::BoundedTextRead::TooLarge) => { + return Err(OpenCodeConfigSnapshotError::Invalid { + path, + message: format!( + "configuration exceeds the {MAX_CONFIG_SNAPSHOT_FILE_BYTES}-byte limit" + ), + }); + } + Ok(bitfun_services_core::bounded_fs::BoundedTextRead::InvalidUtf8) => { + return Err(OpenCodeConfigSnapshotError::Invalid { + path, + message: "configuration must be valid UTF-8".to_string(), + }); + } + Err(source) => return Err(OpenCodeConfigSnapshotError::Read { path, source }), + }; + let content = + substitute_config_variables(&content, &document, workspace).map_err(|message| { + OpenCodeConfigSnapshotError::Invalid { + path: path.clone(), + message, + } + })?; + let value = + serde_json::from_str::(&bitfun_services_core::jsonc::strip_jsonc(&content)) + .map_err(|error| OpenCodeConfigSnapshotError::Invalid { + path: path.clone(), + message: error.to_string(), + })?; + let Value::Object(mut layer) = value else { + return Err(OpenCodeConfigSnapshotError::Invalid { + path, + message: "configuration root must be an object".to_string(), + }); + }; + resolve_config_plugin_specs(&mut layer, &document, workspace).map_err(|message| { + OpenCodeConfigSnapshotError::Invalid { + path: path.clone(), + message, + } + })?; + if document.kind == LocalConfigDocumentKind::User { + // OpenCode first deep-merges its three global config filenames; + // arrays in a later global file replace an earlier one. + merge_config_objects(&mut user_config, layer, false); + continue; + } + if !user_config.is_empty() { + merge_config_objects(&mut config, std::mem::take(&mut user_config), true); + } + merge_config_objects(&mut config, layer, true); + } + if !user_config.is_empty() { + merge_config_objects(&mut config, user_config, true); + } + normalize_merged_config(&mut config); + Ok(OpenCodeConfigSnapshot { config }) +} + +fn merge_config_objects( + target: &mut Map, + source: Map, + merge_open_code_lists: bool, +) { + for (key, source_value) in source { + if merge_open_code_lists && key == "instructions" { + if let Value::Array(source_items) = &source_value { + if let Some(Value::Array(target_items)) = target.get_mut(&key) { + for item in source_items { + if !target_items.contains(item) { + target_items.push(item.clone()); + } + } + } else { + target.insert(key, Value::Array(source_items.clone())); + } + continue; + } + } + if merge_open_code_lists && key == "plugin" { + if let Value::Array(source_items) = &source_value { + if let Some(Value::Array(target_items)) = target.get_mut(&key) { + merge_plugin_specs_keep_last(target_items, source_items); + } else { + let mut merged = Vec::new(); + merge_plugin_specs_keep_last(&mut merged, source_items); + target.insert(key, Value::Array(merged)); + } + continue; + } + } + match (target.get_mut(&key), source_value) { + (Some(Value::Object(target_object)), Value::Object(source_object)) => { + merge_config_objects(target_object, source_object, false); + } + (_, source_value) => { + target.insert(key, source_value); + } + } + } +} + +fn merge_plugin_specs_keep_last(target: &mut Vec, source: &[Value]) { + let mut merged = target + .iter() + .cloned() + .chain(source.iter().cloned()) + .collect::>(); + let mut seen = HashSet::new(); + merged.reverse(); + merged.retain(|item| seen.insert(plugin_load_identity(item))); + merged.reverse(); + *target = merged; +} + +fn plugin_load_identity(plugin: &Value) -> String { + let spec = match plugin { + Value::String(spec) => Some(spec.as_str()), + Value::Array(parts) => parts.first().and_then(Value::as_str), + _ => None, + }; + let Some(spec) = spec else { + return plugin.to_string(); + }; + if spec.starts_with("file://") { + return spec.to_string(); + } + if let Some(scoped) = spec.strip_prefix('@') { + let package_end = scoped + .find('/') + .and_then(|slash| scoped[slash + 1..].find('@').map(|at| slash + 1 + at)); + return package_end + .map(|end| format!("@{}", &scoped[..end])) + .unwrap_or_else(|| spec.to_string()); + } + spec.split_once('@') + .map(|(package, _)| package.to_string()) + .unwrap_or_else(|| spec.to_string()) +} + +fn substitute_config_variables( + content: &str, + document: &LocalConfigDocument, + workspace: &Path, +) -> Result { + static ENV_PATTERN: OnceLock = OnceLock::new(); + static FILE_PATTERN: OnceLock = OnceLock::new(); + let env_pattern = ENV_PATTERN.get_or_init(|| regex::Regex::new(r"\{env:([^}]+)\}").unwrap()); + let file_pattern = FILE_PATTERN.get_or_init(|| regex::Regex::new(r"\{file:([^}]+)\}").unwrap()); + let substituted = env_pattern + .replace_all(content, |captures: ®ex::Captures<'_>| { + std::env::var(&captures[1]).unwrap_or_default() + }) + .into_owned(); + let base = document + .file_path() + .and_then(Path::parent) + .or(document.base_directory.as_deref()) + .unwrap_or(workspace); + let mut output = String::with_capacity(substituted.len()); + let mut cursor = 0; + for captures in file_pattern.captures_iter(&substituted) { + let matched = captures.get(0).expect("complete file token"); + output.push_str(&substituted[cursor..matched.start()]); + let line_start = substituted[..matched.start()] + .rfind('\n') + .map_or(0, |index| index + 1); + if substituted[line_start..matched.start()] + .trim_start() + .starts_with("//") + { + output.push_str(matched.as_str()); + cursor = matched.end(); + continue; + } + let reference = captures.get(1).expect("file reference").as_str(); + let referenced_path = resolve_config_reference_path(reference, base); + let file_content = std::fs::read_to_string(&referenced_path).map_err(|error| { + format!( + "bad file reference {reference:?} ({}): {error}", + referenced_path.display() + ) + })?; + let escaped = + serde_json::to_string(file_content.trim()).map_err(|error| error.to_string())?; + output.push_str(&escaped[1..escaped.len() - 1]); + cursor = matched.end(); + } + output.push_str(&substituted[cursor..]); + Ok(output) +} + +fn resolve_config_reference_path(reference: &str, base: &Path) -> PathBuf { + if let Some(relative) = reference.strip_prefix("~/") { + return dirs::home_dir() + .unwrap_or_else(|| base.to_path_buf()) + .join(relative); + } + let path = PathBuf::from(reference); + if path.is_absolute() { + path + } else { + base.join(path) + } +} + +fn resolve_config_plugin_specs( + config: &mut Map, + document: &LocalConfigDocument, + workspace: &Path, +) -> Result<(), String> { + let Some(Value::Array(plugins)) = config.get_mut("plugin") else { + return Ok(()); + }; + let base = document + .file_path() + .and_then(Path::parent) + .or(document.base_directory.as_deref()) + .unwrap_or(workspace); + for plugin in plugins { + let spec = match plugin { + Value::String(spec) => spec, + Value::Array(parts) => match parts.first_mut() { + Some(Value::String(spec)) => spec, + _ => continue, + }, + _ => continue, + }; + if !plugin_spec_is_path(spec) || spec.starts_with("file://") { + continue; + } + let path = resolve_config_reference_path(spec, base); + *spec = url::Url::from_file_path(&path) + .map_err(|_| { + format!( + "plugin path cannot be represented as a file URL: {}", + path.display() + ) + })? + .to_string(); + } + Ok(()) +} + +fn plugin_spec_is_path(spec: &str) -> bool { + spec.starts_with("file://") + || spec.starts_with("./") + || spec.starts_with("../") + || spec.starts_with("~/") + || Path::new(spec).is_absolute() + || (spec.len() >= 3 + && spec.as_bytes()[0].is_ascii_alphabetic() + && spec.as_bytes()[1] == b':' + && matches!(spec.as_bytes()[2], b'/' | b'\\')) +} + +fn normalize_merged_config(config: &mut Map) { + if let Some(Value::Object(modes)) = config.get("mode").cloned() { + let agents = config + .entry("agent".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(agents) = agents { + for (name, mode) in modes { + let Value::Object(mut mode) = mode else { + continue; + }; + mode.insert("mode".to_string(), Value::String("primary".to_string())); + let mut layer = Map::new(); + layer.insert(name, Value::Object(mode)); + merge_config_objects(agents, layer, false); + } + } + } + if let Some(Value::Object(tools)) = config.get("tools").cloned() { + let mut permissions = Map::new(); + for (tool, enabled) in tools { + let Some(enabled) = enabled.as_bool() else { + continue; + }; + let tool = if matches!(tool.as_str(), "write" | "edit" | "patch") { + "edit".to_string() + } else { + tool + }; + permissions.insert( + tool, + Value::String(if enabled { "allow" } else { "deny" }.to_string()), + ); + } + if let Some(Value::Object(explicit)) = config.get("permission").cloned() { + merge_config_objects(&mut permissions, explicit, false); + } + config.insert("permission".to_string(), Value::Object(permissions)); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct OpenCodeConfig { config_uri: String, @@ -2458,6 +2807,190 @@ mod opencode_projection_contracts { const CONFIG: &str = include_str!("../tests/fixtures/opencode-example/opencode.json"); const LOCAL_PLUGIN_PATH: &str = ".opencode/plugins/workspace-tools.ts"; + + #[test] + fn config_snapshot_loader_preserves_jsonc_without_requiring_schema() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("opencode.jsonc"), + r#"{ + // OpenCode accepts JSONC and does not require $schema. + "agent": { "build": { "prompt": "workspace" } }, + "permission": { "bash": "deny" }, + "plugin": ["alpha", "beta"] + }"#, + ) + .expect("config"); + + let options = isolated_config_options(workspace.path().join("user-config")); + let snapshot = load_opencode_config_snapshot_with_options(workspace.path(), &options) + .expect("snapshot"); + assert_eq!(snapshot.config["agent"]["build"]["prompt"], "workspace"); + assert_eq!(snapshot.config["permission"]["bash"], "deny"); + assert_eq!( + snapshot.config["plugin"], + serde_json::json!(["alpha", "beta"]) + ); + } + + #[test] + fn config_snapshot_loader_deduplicates_plugin_identity_within_one_source() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("opencode.json"), + r#"{ + "plugin": [ + "demo@1", + ["other@1", {"value": "old"}], + "demo@2", + ["other@1", {"value": "new"}] + ] + }"#, + ) + .expect("config"); + + let options = isolated_config_options(workspace.path().join("user-config")); + let snapshot = load_opencode_config_snapshot_with_options(workspace.path(), &options) + .expect("snapshot"); + assert_eq!( + snapshot.config["plugin"], + serde_json::json!(["demo@2", ["other@1", {"value": "new"}]]) + ); + } + + #[test] + fn config_snapshot_loader_merges_user_project_and_inline_precedence() { + let workspace = tempfile::tempdir().expect("workspace"); + let user_config = workspace.path().join("user-config"); + std::fs::create_dir_all(&user_config).expect("user config directory"); + std::fs::write( + user_config.join("config.json"), + r#"{ + "agent": { "build": { "prompt": "user", "model": "small" } }, + "permission": { "bash": "ask" }, + "plugin": ["user-plugin"], + "instructions": ["user.md"] + }"#, + ) + .expect("user config"); + std::fs::write( + user_config.join("opencode.json"), + r#"{ + "plugin": ["user-override-plugin", "demo@1"], + "instructions": ["user-override.md"] + }"#, + ) + .expect("later user config"); + std::fs::write( + workspace.path().join("opencode.json"), + r#"{ + "agent": { "build": { "prompt": "workspace" } }, + "permission": { "bash": "deny" }, + "plugin": ["workspace-plugin", "demo@2"], + "instructions": ["workspace.md"] + }"#, + ) + .expect("workspace config"); + let mut options = isolated_config_options(user_config); + options.inline_config_content = Some( + r#"{ + "agent": { "build": { "prompt": "inline" } }, + "plugin": ["inline-plugin", "user-plugin"], + "instructions": ["inline.md", "user.md"] + }"# + .to_string(), + ); + + let snapshot = load_opencode_config_snapshot_with_options(workspace.path(), &options) + .expect("snapshot"); + assert_eq!(snapshot.config["agent"]["build"]["prompt"], "inline"); + assert_eq!(snapshot.config["agent"]["build"]["model"], "small"); + assert_eq!(snapshot.config["permission"]["bash"], "deny"); + assert_eq!( + snapshot.config["plugin"], + serde_json::json!([ + "user-override-plugin", + "workspace-plugin", + "demo@2", + "inline-plugin", + "user-plugin" + ]) + ); + assert_eq!( + snapshot.config["instructions"], + serde_json::json!(["user-override.md", "workspace.md", "inline.md", "user.md"]) + ); + } + + #[test] + fn config_snapshot_loader_accepts_missing_sources_as_empty() { + let workspace = tempfile::tempdir().expect("workspace"); + let options = isolated_config_options(workspace.path().join("user-config")); + let snapshot = load_opencode_config_snapshot_with_options(workspace.path(), &options) + .expect("snapshot"); + assert!(snapshot.config.is_empty()); + } + + #[test] + fn config_snapshot_loader_substitutes_files_and_resolves_plugin_paths_at_origin() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write(workspace.path().join("prompt.txt"), "line one\nline two\n") + .expect("prompt"); + std::fs::write( + workspace.path().join("opencode.json"), + r#"{ + "agent": { "build": { "prompt": "{file:prompt.txt}" } }, + "plugin": ["./plugins/workspace.ts"] + }"#, + ) + .expect("config"); + + let options = isolated_config_options(workspace.path().join("user-config")); + let snapshot = load_opencode_config_snapshot_with_options(workspace.path(), &options) + .expect("snapshot"); + assert_eq!( + snapshot.config["agent"]["build"]["prompt"], + "line one\nline two" + ); + let expected = url::Url::from_file_path(workspace.path().join("plugins/workspace.ts")) + .expect("plugin URL") + .to_string(); + assert_eq!(snapshot.config["plugin"], serde_json::json!([expected])); + } + + #[test] + fn config_snapshot_loader_applies_legacy_mode_and_tool_normalization() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("opencode.json"), + r#"{ + "agent": { "review": { "prompt": "base" } }, + "mode": { "review": { "prompt": "mode" } }, + "tools": { "write": false, "bash": true }, + "permission": { "bash": "ask" } + }"#, + ) + .expect("config"); + + let options = isolated_config_options(workspace.path().join("user-config")); + let snapshot = load_opencode_config_snapshot_with_options(workspace.path(), &options) + .expect("snapshot"); + assert_eq!(snapshot.config["agent"]["review"]["prompt"], "mode"); + assert_eq!(snapshot.config["agent"]["review"]["mode"], "primary"); + assert_eq!(snapshot.config["permission"]["edit"], "deny"); + assert_eq!(snapshot.config["permission"]["bash"], "ask"); + } + + fn isolated_config_options(user_config_dir: PathBuf) -> OpenCodeLocalConfigOptions { + OpenCodeLocalConfigOptions { + user_config_dir, + legacy_user_config_dir: None, + explicit_config_file: None, + explicit_config_dir: None, + inline_config_content: None, + project_config_enabled: true, + } + } const LOCAL_PLUGIN_SOURCE: &str = include_str!("../tests/fixtures/opencode-example/.opencode/plugins/workspace-tools.ts"); diff --git a/src/crates/adapters/opencode-plugin-host/Cargo.toml b/src/crates/adapters/opencode-plugin-host/Cargo.toml index 54b1cc6e07..5f8cd896b3 100644 --- a/src/crates/adapters/opencode-plugin-host/Cargo.toml +++ b/src/crates/adapters/opencode-plugin-host/Cargo.toml @@ -11,8 +11,10 @@ name = "bitfun_opencode_plugin_host" crate-type = ["rlib"] [dependencies] +async-trait = { workspace = true } base64 = { workspace = true } bitfun-services-core = { path = "../../services/services-core", features = ["process-runtime"] } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["hook-function-runtime"] } log = { workspace = true } rand = { workspace = true } serde = { workspace = true } diff --git a/src/crates/adapters/opencode-plugin-host/src/backend.rs b/src/crates/adapters/opencode-plugin-host/src/backend.rs new file mode 100644 index 0000000000..88ac49daa2 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/backend.rs @@ -0,0 +1,647 @@ +use crate::http::{ + json_error_body, match_http_route, read_host_stream, BackendHttpRequest, BackendHttpResponse, + HostStreamReadError, HttpRouteError, OpenCodeClientRoute, MAX_HTTP_BODY_BYTES, +}; +use crate::stream_registry::{ + PluginHostStreamRegistry, StreamCancelParams, StreamReadParams, StreamRegistryError, +}; +use crate::{PluginHostClient, PluginHostError, RpcHandlerError}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{watch, Notify}; + +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const HTTP_DRAIN_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone)] +pub struct BackendRouteRequest { + pub instance_id: String, + pub route: OpenCodeClientRoute, + pub query: HashMap>, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl BackendRouteRequest { + pub fn query_first(&self, key: &str) -> Option<&str> { + self.query + .get(key) + .and_then(|values| values.first()) + .map(String::as_str) + } +} + +#[derive(Debug, Clone)] +pub struct BackendRouteFailure { + kind: BackendRouteFailureKind, + message: String, +} + +#[derive(Debug, Clone, Copy)] +enum BackendRouteFailureKind { + BadRequest, + Forbidden, + NotFound, + Unsupported, + Backend, + Unavailable, +} + +impl BackendRouteFailure { + pub fn bad_request(message: impl Into) -> Self { + Self::new(BackendRouteFailureKind::BadRequest, message) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::new(BackendRouteFailureKind::Forbidden, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(BackendRouteFailureKind::NotFound, message) + } + + pub fn unsupported(message: impl Into) -> Self { + Self::new(BackendRouteFailureKind::Unsupported, message) + } + + pub fn backend(message: impl Into) -> Self { + Self::new(BackendRouteFailureKind::Backend, message) + } + + pub fn unavailable(message: impl Into) -> Self { + Self::new(BackendRouteFailureKind::Unavailable, message) + } + + pub fn message(&self) -> &str { + &self.message + } + + fn new(kind: BackendRouteFailureKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + fn status_and_code(&self) -> (u16, &'static str) { + match self.kind { + BackendRouteFailureKind::BadRequest => (400, "invalid_request"), + BackendRouteFailureKind::Forbidden => (403, "instance_scope_denied"), + BackendRouteFailureKind::NotFound => (404, "not_found"), + BackendRouteFailureKind::Unsupported => (501, "unsupported_capability"), + BackendRouteFailureKind::Backend => (502, "backend_failure"), + BackendRouteFailureKind::Unavailable => (503, "backend_unavailable"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendDiagnosticSeverity { + Debug, + Info, + Warning, + Error, +} + +impl BackendDiagnosticSeverity { + pub fn as_str(self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Info => "info", + Self::Warning => "warning", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone)] +pub struct BackendDiagnostic { + pub severity: BackendDiagnosticSeverity, + pub code: String, + pub message: String, + pub plugin: Option, + pub method: Option, + pub data: Option, +} + +#[derive(Debug, Clone)] +pub struct BackendDiagnosticEvent { + pub instance_id: Option, + pub diagnostic: BackendDiagnostic, +} + +#[derive(Debug, Clone)] +pub enum BackendDiagnosticError { + Unavailable(String), + Backend(String), +} + +#[async_trait] +pub trait OpenCodeBackendHandler: Send + Sync { + async fn handle_route( + &self, + request: BackendRouteRequest, + ) -> Result; + + async fn publish_diagnostic( + &self, + event: BackendDiagnosticEvent, + ) -> Result<(), BackendDiagnosticError>; +} + +pub struct PluginHostBackendBridge { + client: PluginHostClient, + handler: Arc, + streams: PluginHostStreamRegistry, + drain_tx: watch::Sender, + draining: AtomicBool, + active_requests: AtomicUsize, + requests_drained: Notify, +} + +struct ActiveRequest<'a> { + bridge: &'a PluginHostBackendBridge, + drain_rx: watch::Receiver, +} + +impl Drop for ActiveRequest<'_> { + fn drop(&mut self) { + if self.bridge.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { + self.bridge.requests_drained.notify_waiters(); + } + } +} + +impl PluginHostBackendBridge { + fn new(client: PluginHostClient, handler: Arc) -> Self { + let (drain_tx, _) = watch::channel(false); + Self { + client, + handler, + streams: PluginHostStreamRegistry::default(), + drain_tx, + draining: AtomicBool::new(false), + active_requests: AtomicUsize::new(0), + requests_drained: Notify::new(), + } + } + + fn admit(&self) -> Option> { + let drain_rx = self.drain_tx.subscribe(); + if self.draining.load(Ordering::Acquire) { + return None; + } + self.active_requests.fetch_add(1, Ordering::AcqRel); + if self.draining.load(Ordering::Acquire) || *drain_rx.borrow() { + if self.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { + self.requests_drained.notify_waiters(); + } + return None; + } + Some(ActiveRequest { + bridge: self, + drain_rx, + }) + } + + async fn handle_http(self: Arc, params: Value) -> Result { + let request: BackendHttpRequest = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.http.request", error))?; + if request.instance_id.trim().is_empty() + || request.instance_id.len() > 256 + || request.request_id.trim().is_empty() + || request.request_id.len() > 256 + || request.method.len() > 16 + || request.headers.len() > 64 + { + return Err(RpcHandlerError::new( + -32602, + "Invalid request identity, method, or header count for backend.http.request", + )); + } + let started_at = Instant::now(); + let path = request.path.clone(); + let method = request.method.clone(); + let instance_id = request.instance_id.clone(); + let request_id = request.request_id.clone(); + let Some(mut active) = self.admit() else { + return self + .http_error( + &instance_id, + 503, + "host_draining", + "Plugin host is shutting down", + &path, + ) + .await; + }; + + let route_match = match match_http_route(&method, &path) { + Ok(route_match) => route_match, + Err(HttpRouteError::InvalidPath) => { + return self + .http_error( + &instance_id, + 400, + "invalid_request", + "Request path is invalid", + &path, + ) + .await; + } + Err(HttpRouteError::NotFound) => { + return self + .http_error( + &instance_id, + 404, + "route_not_found", + "OpenCode client route was not found", + &path, + ) + .await; + } + Err(HttpRouteError::MethodNotAllowed) => { + return self + .http_error( + &instance_id, + 405, + "method_not_allowed", + "HTTP method is not allowed for this route", + &path, + ) + .await; + } + }; + let operation = route_match.route.operation(); + let body = tokio::select! { + _ = active.drain_rx.changed() => return Err(draining_rpc_error()), + body = async { + match request.body.as_ref() { + Some(descriptor) => read_host_stream( + &self.client, + &instance_id, + descriptor, + MAX_HTTP_BODY_BYTES, + HTTP_REQUEST_TIMEOUT, + ) + .await + .map(Some), + None => Ok(None), + } + } => match body { + Ok(Some(body)) => body, + Ok(None) => Vec::new(), + Err(HostStreamReadError::BodyTooLarge) => { + return self + .http_error( + &instance_id, + 413, + "request_too_large", + "Request body exceeds the configured limit", + &path, + ) + .await; + } + Err(error) => { + return self + .http_error( + &instance_id, + 502, + "backend_failure", + &format!("Failed to read request body: {error}"), + &path, + ) + .await; + } + } + }; + + let outcome = tokio::select! { + _ = active.drain_rx.changed() => return Err(draining_rpc_error()), + outcome = tokio::time::timeout( + HTTP_REQUEST_TIMEOUT, + self.handler.handle_route(BackendRouteRequest { + instance_id: instance_id.clone(), + route: route_match.route, + query: route_match.query, + headers: request.headers, + body, + }), + ) => outcome, + }; + let response = match outcome { + Ok(Ok(value)) => self.json_response(&instance_id, 200, value).await, + Ok(Err(error)) => { + let (status, code) = error.status_and_code(); + self.http_error(&instance_id, status, code, &error.message, &path) + .await + } + Err(_) => { + self.http_error( + &instance_id, + 504, + "backend_timeout", + "Backend route timed out", + &path, + ) + .await + } + }; + let status = response + .as_ref() + .ok() + .and_then(|value| value.get("status")) + .and_then(Value::as_u64) + .unwrap_or(500); + log::info!( + "Plugin client request completed: instance_id={}, request_id={}, method={}, path={}, status={}, duration_ms={}, route_status=A, operation={}", + instance_id, + request_id, + method, + path, + status, + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + operation + ); + response + } + + async fn json_response( + &self, + instance_id: &str, + status: u16, + value: Value, + ) -> Result { + let bytes = serde_json::to_vec(&value).map_err(|error| { + RpcHandlerError::new( + -32603, + format!("Failed to serialize HTTP response: {error}"), + ) + })?; + self.bytes_response(instance_id, status, "application/json", bytes) + .await + } + + async fn http_error( + &self, + instance_id: &str, + status: u16, + code: &str, + message: &str, + route: &str, + ) -> Result { + self.bytes_response( + instance_id, + status, + "application/json", + json_error_body(code, message, route), + ) + .await + } + + async fn bytes_response( + &self, + instance_id: &str, + status: u16, + content_type: &str, + bytes: Vec, + ) -> Result { + let body = self + .streams + .add(instance_id, bytes) + .await + .map_err(stream_rpc_error)?; + serde_json::to_value(BackendHttpResponse { + status, + status_text: None, + headers: vec![("content-type".to_string(), content_type.to_string())], + body: Some(body), + }) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + + async fn handle_diagnostic(&self, params: Value) -> Result { + let params: RawDiagnosticPublishParams = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.diagnostic.publish", error))?; + let severity = match params.diagnostic.severity.as_str() { + "debug" => BackendDiagnosticSeverity::Debug, + "info" => BackendDiagnosticSeverity::Info, + "warning" => BackendDiagnosticSeverity::Warning, + "error" => BackendDiagnosticSeverity::Error, + _ => { + return Err(RpcHandlerError::new( + -32602, + "backend.diagnostic.publish severity is invalid", + )); + } + }; + self.handler + .publish_diagnostic(BackendDiagnosticEvent { + instance_id: params.instance_id, + diagnostic: BackendDiagnostic { + severity, + code: params.diagnostic.code, + message: params.diagnostic.message, + plugin: params.diagnostic.plugin, + method: params.diagnostic.method, + data: params.diagnostic.data, + }, + }) + .await + .map_err(diagnostic_rpc_error)?; + Ok(serde_json::json!({})) + } + + pub async fn begin_draining(&self) -> bool { + self.draining.store(true, Ordering::Release); + let _ = self.drain_tx.send(true); + let active_requests = self.active_requests.load(Ordering::Acquire); + let active_streams = self.streams.active_count().await; + log::info!( + "Plugin client bridge draining started: active_requests={}, active_streams={}", + active_requests, + active_streams + ); + let wait = async { + loop { + let notified = self.requests_drained.notified(); + if self.active_requests.load(Ordering::Acquire) == 0 { + return; + } + notified.await; + } + }; + let requests_drained = tokio::time::timeout(HTTP_DRAIN_TIMEOUT, wait).await.is_ok(); + if !requests_drained { + log::warn!( + "Plugin client bridge request drain timed out: active_requests={}", + self.active_requests.load(Ordering::Acquire) + ); + } + let streams_drained = self.streams.wait_until_empty(HTTP_DRAIN_TIMEOUT).await; + if !streams_drained { + log::warn!( + "Plugin client bridge response stream drain timed out: active_streams={}", + self.streams.active_count().await + ); + } + let cancelled = self.streams.cancel_all().await; + log::info!( + "Plugin client bridge draining completed: active_requests={}, cancelled_streams={}", + self.active_requests.load(Ordering::Acquire), + cancelled + ); + requests_drained + } + + pub async fn cancel_instance_streams(&self, instance_id: &str) { + let cancelled = self.streams.cancel_instance(instance_id).await; + if cancelled > 0 { + log::debug!( + "Plugin client response streams cancelled: instance_id={}, stream_count={}", + instance_id, + cancelled + ); + } + } +} + +pub async fn register_backend_handlers( + client: PluginHostClient, + handler: Arc, +) -> Result, PluginHostError> { + let bridge = Arc::new(PluginHostBackendBridge::new(client.clone(), handler)); + let http_bridge = Arc::downgrade(&bridge); + client + .register_handler("backend.http.request", move |params| { + let bridge = http_bridge.clone(); + async move { + bridge + .upgrade() + .ok_or_else(backend_bridge_unavailable)? + .handle_http(params) + .await + } + }) + .await?; + let read_bridge = Arc::downgrade(&bridge); + client + .register_handler("backend.stream.read", move |params| { + let bridge = read_bridge.clone(); + async move { + let bridge = bridge.upgrade().ok_or_else(backend_bridge_unavailable)?; + let params: StreamReadParams = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.stream.read", error))?; + serde_json::to_value( + bridge + .streams + .read(params) + .await + .map_err(stream_rpc_error)?, + ) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + }) + .await?; + let cancel_bridge = Arc::downgrade(&bridge); + client + .register_handler("backend.stream.cancel", move |params| { + let bridge = cancel_bridge.clone(); + async move { + let bridge = bridge.upgrade().ok_or_else(backend_bridge_unavailable)?; + serde_json::to_value( + bridge + .streams + .cancel( + serde_json::from_value::(params).map_err( + |error| invalid_rpc_params("backend.stream.cancel", error), + )?, + ) + .await + .map_err(stream_rpc_error)?, + ) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + }) + .await?; + let diagnostic_bridge = Arc::downgrade(&bridge); + client + .register_handler("backend.diagnostic.publish", move |params| { + let bridge = diagnostic_bridge.clone(); + async move { + bridge + .upgrade() + .ok_or_else(backend_bridge_unavailable)? + .handle_diagnostic(params) + .await + } + }) + .await?; + Ok(bridge) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDiagnostic { + severity: String, + code: String, + message: String, + plugin: Option, + method: Option, + data: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDiagnosticPublishParams { + #[serde(rename = "instanceID")] + instance_id: Option, + diagnostic: RawDiagnostic, +} + +fn invalid_rpc_params(method: &str, error: serde_json::Error) -> RpcHandlerError { + RpcHandlerError::new(-32602, format!("Invalid parameters for {method}: {error}")) +} + +fn draining_rpc_error() -> RpcHandlerError { + RpcHandlerError::new(-32000, "Plugin host backend is draining") +} + +fn backend_bridge_unavailable() -> RpcHandlerError { + RpcHandlerError::new(-32004, "Plugin host backend bridge is unavailable") +} + +fn stream_rpc_error(error: StreamRegistryError) -> RpcHandlerError { + match error { + StreamRegistryError::InstanceMismatch => RpcHandlerError::new(-32003, error.to_string()), + StreamRegistryError::InvalidMaxBytes => RpcHandlerError::new(-32602, error.to_string()), + StreamRegistryError::Capacity | StreamRegistryError::BodyTooLarge => { + RpcHandlerError::new(-32000, error.to_string()) + } + } +} + +fn diagnostic_rpc_error(error: BackendDiagnosticError) -> RpcHandlerError { + match error { + BackendDiagnosticError::Unavailable(message) => RpcHandlerError::new(-32004, message), + BackendDiagnosticError::Backend(message) => RpcHandlerError::new(-32603, message), + } +} + +#[cfg(test)] +mod tests { + use super::BackendRouteFailure; + + #[test] + fn route_failure_mapping_stays_adapter_owned() { + assert_eq!( + BackendRouteFailure::unsupported("retired").status_and_code(), + (501, "unsupported_capability") + ); + } +} diff --git a/src/crates/adapters/opencode-plugin-host/src/http.rs b/src/crates/adapters/opencode-plugin-host/src/http.rs index 7bd5b6f952..3e2c2799ef 100644 --- a/src/crates/adapters/opencode-plugin-host/src/http.rs +++ b/src/crates/adapters/opencode-plugin-host/src/http.rs @@ -8,12 +8,12 @@ use std::time::Duration; use thiserror::Error; use url::Url; -pub const MAX_HTTP_BODY_BYTES: usize = 1024 * 1024; -pub const MAX_STREAM_CHUNK_BYTES: usize = 64 * 1024; +pub(crate) const MAX_HTTP_BODY_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_STREAM_CHUNK_BYTES: usize = 64 * 1024; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct BackendHttpRequest { +pub(crate) struct BackendHttpRequest { #[serde(rename = "instanceID")] pub instance_id: String, #[serde(rename = "requestID")] @@ -26,7 +26,7 @@ pub struct BackendHttpRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct StreamDescriptor { +pub(crate) struct StreamDescriptor { #[serde(rename = "streamID")] pub stream_id: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -35,7 +35,7 @@ pub struct StreamDescriptor { #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct BackendHttpResponse { +pub(crate) struct BackendHttpResponse { pub status: u16, #[serde(skip_serializing_if = "Option::is_none")] pub status_text: Option, @@ -158,23 +158,14 @@ impl OpenCodeClientRoute { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct HttpRouteMatch { +pub(crate) struct HttpRouteMatch { pub route: OpenCodeClientRoute, pub path: String, pub query: HashMap>, } -impl HttpRouteMatch { - pub fn query_first(&self, key: &str) -> Option<&str> { - self.query - .get(key) - .and_then(|values| values.first()) - .map(String::as_str) - } -} - #[derive(Debug, Error, Clone, PartialEq, Eq)] -pub enum HttpRouteError { +pub(crate) enum HttpRouteError { #[error("request path is invalid")] InvalidPath, #[error("OpenCode client route was not found")] @@ -183,7 +174,7 @@ pub enum HttpRouteError { MethodNotAllowed, } -pub fn match_http_route( +pub(crate) fn match_http_route( method: &str, path_and_query: &str, ) -> Result { @@ -378,7 +369,7 @@ fn is_known_adapted_path(path: &str, segments: &[String]) -> bool { } #[derive(Debug, Error)] -pub enum HostStreamReadError { +pub(crate) enum HostStreamReadError { #[error("request body exceeds the maximum allowed size")] BodyTooLarge, #[error("host stream returned invalid base64 data: {0}")] @@ -389,7 +380,7 @@ pub enum HostStreamReadError { InvalidResponse, } -pub async fn read_host_stream( +pub(crate) async fn read_host_stream( client: &PluginHostClient, instance_id: &str, descriptor: &StreamDescriptor, @@ -472,7 +463,7 @@ async fn cancel_host_stream( .await; } -pub fn json_error_body(code: &str, message: &str, route: &str) -> Vec { +pub(crate) fn json_error_body(code: &str, message: &str, route: &str) -> Vec { serde_json::to_vec(&json!({ "error": { "code": code, @@ -656,7 +647,14 @@ mod tests { assert_eq!(matched.route, OpenCodeClientRoute::ProjectCurrent); assert_eq!(matched.path, "/project/current"); - assert_eq!(matched.query_first("directory"), Some("C:\\workspace")); + assert_eq!( + matched + .query + .get("directory") + .and_then(|values| values.first()) + .map(String::as_str), + Some("C:\\workspace") + ); assert_eq!( matched.query.get("directory"), Some(&vec![ diff --git a/src/crates/adapters/opencode-plugin-host/src/lib.rs b/src/crates/adapters/opencode-plugin-host/src/lib.rs index 52b206da72..e14282d74f 100644 --- a/src/crates/adapters/opencode-plugin-host/src/lib.rs +++ b/src/crates/adapters/opencode-plugin-host/src/lib.rs @@ -1,3 +1,4 @@ +mod backend; mod frame; mod host_log; mod http; @@ -7,36 +8,88 @@ mod stream_registry; use bitfun_services_core::process_tree::{CleanupOutcome, ProcessTreeChild}; use rand::{distributions::Alphanumeric, Rng}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use std::time::Duration; use std::time::Instant; use thiserror::Error; use tokio::net::{TcpListener, TcpStream}; use tokio::process::Command; -use frame::{read_frame, write_frame}; -pub use http::{ - json_error_body, match_http_route, read_host_stream, BackendHttpRequest, BackendHttpResponse, - HostStreamReadError, HttpRouteError, HttpRouteMatch, OpenCodeClientRoute, StreamDescriptor, - MAX_HTTP_BODY_BYTES, MAX_STREAM_CHUNK_BYTES, +pub use backend::{ + register_backend_handlers, BackendDiagnostic, BackendDiagnosticError, BackendDiagnosticEvent, + BackendDiagnosticSeverity, BackendRouteFailure, BackendRouteRequest, OpenCodeBackendHandler, + PluginHostBackendBridge, }; -pub use peer::{JsonRpcPeer, PluginHostClient, RpcHandlerError}; -pub use stream_registry::{ - PluginHostStreamRegistry, StreamCancelParams, StreamCancelResult, StreamReadParams, - StreamReadResult, StreamRegistryError, +use frame::{read_frame, write_frame}; +pub use http::OpenCodeClientRoute; +#[cfg(test)] +pub(crate) use http::{read_host_stream, HostStreamReadError, StreamDescriptor}; +pub use peer::{ + hook_function_runtime, JsonRpcPeer, OpenCodeHookFunctionRuntime, PluginHostClient, + RpcHandlerError, }; const PROTOCOL_VERSION: u64 = 1; -const MIN_NEGOTIATED_FRAME_BYTES: usize = 64 * 1024; const DEFAULT_MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +const STARTUP_TERMINATE_GRACE: Duration = Duration::from_millis(500); +const MIN_NEGOTIATED_FRAME_BYTES: usize = 1024; +pub const CONFIG_CONTRIBUTORS_V1: &str = "config-contributors-v1"; +pub const CONFIG_CONTRIBUTIONS_V2: &str = "config-contributions-v2"; +pub const GENERATION_FENCING_V1: &str = "generation-fencing-v1"; static NEXT_CONNECTION_GENERATION: AtomicU64 = AtomicU64::new(1); +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginHostCapabilities { + negotiated: BTreeSet, +} + +impl PluginHostCapabilities { + pub fn all_supported() -> Self { + Self { + negotiated: [ + CONFIG_CONTRIBUTORS_V1, + CONFIG_CONTRIBUTIONS_V2, + GENERATION_FENCING_V1, + ] + .into_iter() + .map(str::to_string) + .collect(), + } + } + + pub fn supports(&self, capability: &str) -> bool { + self.negotiated.contains(capability) + } + + pub fn values(&self) -> impl Iterator { + self.negotiated.iter().map(String::as_str) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginGenerationLease { + #[serde(rename = "instanceID")] + pub instance_id: String, + pub generation_key: String, + pub revision: String, +} + +#[derive(Debug)] +struct HandshakeNegotiation { + max_frame_bytes: usize, + capabilities: PluginHostCapabilities, +} + #[derive(Debug, Clone)] pub struct PluginHostConfig { pub runtime_command: PathBuf, @@ -65,6 +118,19 @@ pub struct PluginPrepareRequest { pub configuration_fingerprint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_base_directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_install: Option, +} + +/// Adapter-owned, protocol-independent facts needed by product assembly to +/// fence a reviewed plugin graph before import. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginPreparationSummary { + pub review_digest: String, + pub reviewed_count: usize, + pub prepared_count: usize, + pub failed_count: usize, + pub content_digests: BTreeMap, } #[derive(Debug, Clone, Serialize)] @@ -72,6 +138,8 @@ pub struct PluginPrepareRequest { pub struct PluginInstanceOpenRequest { #[serde(rename = "instanceID")] pub instance_id: String, + pub generation_key: String, + pub revision: String, pub project: Value, pub config: serde_json::Map, pub directory: String, @@ -79,6 +147,10 @@ pub struct PluginInstanceOpenRequest { pub plugins: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub configuration_fingerprint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_content_digests: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_review_digest: Option, } #[derive(Debug, Error)] @@ -93,20 +165,12 @@ pub enum PluginHostError { PrepareCache(#[source] std::io::Error), #[error("failed to bind plugin host listener: {0}")] Bind(#[source] std::io::Error), - #[error("plugin host runtime executable was not found: {0}")] - RuntimeNotFound(PathBuf), #[error("failed to start plugin host runtime: {0}")] Spawn(#[source] std::io::Error), #[error("failed to prepare plugin host log: {0}")] PrepareLog(#[source] std::io::Error), #[error("plugin host did not connect within the startup timeout")] StartupTimeout, - #[error("plugin host startup failed ({startup}) and process-tree cleanup failed: {cleanup}")] - StartupCleanup { - startup: String, - #[source] - cleanup: std::io::Error, - }, #[error("plugin host IPC failed: {0}")] Io(#[source] std::io::Error), #[error("plugin host handshake frame is invalid: {0}")] @@ -119,6 +183,15 @@ pub enum PluginHostError { ShuttingDown, #[error("plugin host JSON-RPC request timed out: method={method}, request_id={request_id}")] RequestTimeout { method: String, request_id: String }, + #[error("plugin tool execution stopped after its deadline: execution_id={execution_id}")] + ToolStoppedAfterTimeout { execution_id: String }, + #[error( + "plugin tool execution outcome is unknown: execution_id={execution_id}, reason={reason}" + )] + ToolOutcomeUnknown { + execution_id: String, + reason: String, + }, #[error("plugin host JSON-RPC returned an error: code={code}, message={message}")] Rpc { code: i64, @@ -132,6 +205,7 @@ pub enum PluginHostError { pub struct PluginHost { child: ProcessTreeChild, client: PluginHostClient, + runtime: Arc, host_log: Option, max_frame_bytes: usize, } @@ -169,7 +243,6 @@ pub enum PluginHostShutdownDisposition { pub struct PluginHostShutdownReport { pub generation: u64, pub disposition: PluginHostShutdownDisposition, - pub reaped: bool, pub rpc_completed: bool, pub exit_code: Option, pub duration_ms: u64, @@ -180,7 +253,7 @@ impl PluginHost { Self::start_with_timeout(config, STARTUP_TIMEOUT).await } - async fn start_with_timeout( + pub async fn start_with_timeout( config: PluginHostConfig, startup_timeout: Duration, ) -> Result { @@ -210,45 +283,43 @@ impl PluginHost { .stderr(Stdio::piped()); let mut child = ProcessTreeChild::spawn(&mut command) .await - .map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - PluginHostError::RuntimeNotFound(config.runtime_command.clone()) - } else { - PluginHostError::Spawn(error) - } - })?; - let host_log = match host_log::attach_host_log(&mut child, &config.log_file).await { - Ok(host_log) => host_log, - Err(error) => { - return Err(cleanup_failed_start( - &mut child, - None, - PluginHostError::PrepareLog(error), - ) - .await); - } - }; + .map_err(PluginHostError::Spawn)?; + let host_log = host_log::attach_host_log(&mut child, &config.log_file) + .await + .map_err(PluginHostError::PrepareLog)?; - let (stream, max_frame_bytes) = match accept_authenticated_connection( + let connection = accept_authenticated_connection( &listener, &token, &config.cache_directory, startup_timeout, ) - .await - { + .await; + let (stream, negotiation) = match connection { Ok(connection) => connection, Err(error) => { - return Err(cleanup_failed_start(&mut child, Some(host_log), error).await); + if let Err(cleanup_error) = child.terminate(STARTUP_TERMINATE_GRACE).await { + log::error!("Plugin host startup cleanup failed: {cleanup_error}"); + } + let _ = host_log.flush(Duration::from_secs(1)).await; + return Err(error); } }; let generation = NEXT_CONNECTION_GENERATION.fetch_add(1, Ordering::Relaxed); - let peer = JsonRpcPeer::start(stream, generation, max_frame_bytes); + let peer = JsonRpcPeer::start_with_capabilities( + stream, + generation, + negotiation.max_frame_bytes, + negotiation.capabilities, + ); + let client = peer.client(); + let runtime = hook_function_runtime(client.clone()); Ok(Self { child, - client: peer.client(), + client, + runtime, host_log: Some(host_log), - max_frame_bytes, + max_frame_bytes: negotiation.max_frame_bytes, }) } @@ -260,6 +331,10 @@ impl PluginHost { self.client.clone() } + pub fn runtime(&self) -> Arc { + self.runtime.clone() + } + pub fn is_connected(&mut self) -> Result { if self .child @@ -310,10 +385,8 @@ impl PluginHost { } else { PluginHostShutdownDisposition::ExitedAfterShutdown }; - let mut report = + let report = shutdown_report(generation, disposition, true, status.code(), started_at); - report.reaped = - reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; if report.disposition == PluginHostShutdownDisposition::Graceful { log::info!( "Plugin host exited gracefully: generation={}, exit_code={:?}, duration_ms={}", @@ -347,15 +420,13 @@ impl PluginHost { .close("plugin host graceful shutdown fallback") .await; if let Ok(Ok(status)) = tokio::time::timeout(policy.eof_timeout, self.child.wait()).await { - let mut report = shutdown_report( + let report = shutdown_report( generation, PluginHostShutdownDisposition::ExitedAfterConnectionClose, rpc_completed, status.code(), started_at, ); - report.reaped = - reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; log::info!( "Plugin host exited after RPC connection close: generation={}, exit_code={:?}, duration_ms={}", generation, @@ -366,21 +437,37 @@ impl PluginHost { return report; } - let reaped = reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; + let cleanup = self.child.terminate(policy.terminate_grace).await; let exit_code = self .child .try_wait() .ok() .flatten() .and_then(|status| status.code()); - let mut report = shutdown_report( + let report = shutdown_report( generation, PluginHostShutdownDisposition::Forced, rpc_completed, exit_code, started_at, ); - report.reaped = reaped; + match cleanup { + Ok(CleanupOutcome::AlreadyExited) => log::warn!( + "Plugin host exited during forced cleanup: generation={}, duration_ms={}", + generation, + report.duration_ms + ), + Ok(_) => log::warn!( + "Plugin host process tree terminated: generation={}, duration_ms={}", + generation, + report.duration_ms + ), + Err(error) => log::error!( + "Plugin host process tree termination failed: generation={}, error={}", + generation, + error + ), + } self.flush_host_log(policy.eof_timeout).await; report } @@ -398,57 +485,16 @@ impl PluginHost { } } -async fn reap_process_tree(child: &mut ProcessTreeChild, grace: Duration, generation: u64) -> bool { - match child.terminate(grace).await { - Ok(CleanupOutcome::AlreadyExited) => { - log::info!("Plugin host process tree already exited: generation={generation}"); - true - } - Ok(_) => { - log::info!("Plugin host process tree reaped: generation={generation}"); - true - } - Err(error) => { - log::error!( - "Plugin host process tree termination failed: generation={}, error={}", - generation, - error - ); - false - } - } -} - -async fn cleanup_failed_start( - child: &mut ProcessTreeChild, - host_log: Option, - startup: PluginHostError, -) -> PluginHostError { - let policy = PluginHostShutdownPolicy::default(); - let cleanup = child.terminate(policy.terminate_grace).await; - if let Some(host_log) = host_log { - let _ = host_log.flush(policy.eof_timeout).await; - } - match cleanup { - Ok(_) => startup, - Err(cleanup) => PluginHostError::StartupCleanup { - startup: startup.to_string(), - cleanup, - }, - } -} - async fn accept_authenticated_connection( listener: &TcpListener, - expected_token: &str, + token: &str, cache_directory: &Path, startup_timeout: Duration, -) -> Result<(TcpStream, usize), PluginHostError> { +) -> Result<(TcpStream, HandshakeNegotiation), PluginHostError> { tokio::time::timeout(startup_timeout, async { let (mut stream, _) = listener.accept().await.map_err(PluginHostError::Io)?; - let max_frame_bytes = - complete_handshake(&mut stream, expected_token, cache_directory).await?; - Ok((stream, max_frame_bytes)) + let negotiation = complete_handshake(&mut stream, token, cache_directory).await?; + Ok((stream, negotiation)) }) .await .map_err(|_| PluginHostError::StartupTimeout)? @@ -464,7 +510,6 @@ fn shutdown_report( PluginHostShutdownReport { generation, disposition, - reaped: false, rpc_completed, exit_code, duration_ms: elapsed_ms(started_at), @@ -494,7 +539,7 @@ async fn complete_handshake( stream: &mut TcpStream, expected_token: &str, cache_directory: &Path, -) -> Result { +) -> Result { let request = read_frame(stream, DEFAULT_MAX_FRAME_BYTES).await?; let jsonrpc = request.get("jsonrpc").and_then(Value::as_str); let method = request.get("method").and_then(Value::as_str); @@ -513,6 +558,16 @@ async fn complete_handshake( .and_then(|params| params.get("maxFrameBytes")) .and_then(Value::as_u64) .and_then(|value| usize::try_from(value).ok()); + let requested_capabilities = params + .and_then(|params| params.get("capabilities")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); if jsonrpc != Some("2.0") || method != Some("backend.handshake") || request_id.is_none() @@ -527,18 +582,32 @@ async fn complete_handshake( } let max_frame_bytes = requested_frame_bytes .unwrap_or(DEFAULT_MAX_FRAME_BYTES) - .clamp(MIN_NEGOTIATED_FRAME_BYTES, MAX_FRAME_BYTES); + .max(MIN_NEGOTIATED_FRAME_BYTES) + .min(MAX_FRAME_BYTES); + let negotiated = [ + CONFIG_CONTRIBUTORS_V1, + CONFIG_CONTRIBUTIONS_V2, + GENERATION_FENCING_V1, + ] + .into_iter() + .filter(|capability| requested_capabilities.contains(capability)) + .map(str::to_string) + .collect::>(); let response = json!({ "jsonrpc": "2.0", "id": request_id, "result": { "protocolVersion": PROTOCOL_VERSION, "maxFrameBytes": max_frame_bytes, - "cacheDirectory": cache_directory.to_string_lossy() + "cacheDirectory": cache_directory.to_string_lossy(), + "capabilities": negotiated } }); write_frame(stream, &response, DEFAULT_MAX_FRAME_BYTES).await?; - Ok(max_frame_bytes) + Ok(HandshakeNegotiation { + max_frame_bytes, + capabilities: PluginHostCapabilities { negotiated }, + }) } #[cfg(test)] diff --git a/src/crates/adapters/opencode-plugin-host/src/peer.rs b/src/crates/adapters/opencode-plugin-host/src/peer.rs index e2ebf9719c..a023289d48 100644 --- a/src/crates/adapters/opencode-plugin-host/src/peer.rs +++ b/src/crates/adapters/opencode-plugin-host/src/peer.rs @@ -1,7 +1,23 @@ use crate::peer_runtime::{run_reader, run_writer}; -use crate::{PluginHostError, PluginInstanceOpenRequest, PluginPrepareRequest}; +use crate::{ + PluginGenerationLease, PluginHostCapabilities, PluginHostError, PluginInstanceOpenRequest, + PluginPreparationSummary, PluginPrepareRequest, GENERATION_FENCING_V1, +}; +use bitfun_runtime_ports::{ + HookFunctionAfterRequest, HookFunctionAfterResult, HookFunctionAvailability, + HookFunctionBeforeRequest, HookFunctionBeforeResult, HookFunctionCancelRequest, + HookFunctionCancelResult, HookFunctionConfigContribution, HookFunctionConfigContributor, + HookFunctionDiagnostic, HookFunctionDisposeRequest, HookFunctionDisposeResult, + HookFunctionGeneration, HookFunctionHookKind, HookFunctionPluginDeclaration, + HookFunctionPluginIdentity, HookFunctionRegistrationBatch, HookFunctionRegistrationSink, + HookFunctionReverseAsk, HookFunctionReverseMetadata, HookFunctionReverseReply, + HookFunctionReverseSink, HookFunctionRuntime, HookFunctionStartRequest, + HookFunctionToolAttachment, HookFunctionToolRegistration, HookFunctionToolRequest, + HookFunctionToolResult, PortError, PortErrorKind, PortResult, +}; +use serde::Deserialize; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::pin::Pin; use std::sync::{ @@ -20,6 +36,26 @@ pub(super) type HandlerFuture = pub(super) type Handler = Arc HandlerFuture + Send + Sync>; pub(super) type PendingSender = oneshot::Sender>; +/// Removes an admitted RPC when its future is dropped by an outer timeout or +/// cancellation. The response reader normally removes pending entries first; +/// this guard closes the cancellation-safety gap for callers that stop polling. +struct PendingRequestGuard { + state: Arc, + request_id: String, +} + +impl Drop for PendingRequestGuard { + fn drop(&mut self) { + let state = self.state.clone(); + let request_id = self.request_id.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + state.remove_pending(&request_id).await; + }); + } + } +} + #[derive(Debug, Clone)] pub struct RpcHandlerError { pub code: i64, @@ -42,6 +78,713 @@ pub struct PluginHostClient { state: Arc, } +type RuntimeGenerationKey = (String, String, String); + +/// Typed OpenCode hook/function data plane. OpenCode JSON-RPC wire shapes are +/// decoded here so capability owners only see provider-neutral contracts. +pub struct OpenCodeHookFunctionRuntime { + client: PluginHostClient, + reverse_sinks: Arc>>>, + reverse_handlers_registered: Mutex, +} + +impl OpenCodeHookFunctionRuntime { + pub fn new(client: PluginHostClient) -> Self { + Self { + client, + reverse_sinks: Arc::new(RwLock::new(HashMap::new())), + reverse_handlers_registered: Mutex::new(false), + } + } + + async fn ensure_reverse_handlers(&self) -> PortResult<()> { + let mut registered = self.reverse_handlers_registered.lock().await; + if *registered { + return Ok(()); + } + + let metadata_sinks = self.reverse_sinks.clone(); + self.client + .register_handler("backend.tool.metadata", move |params| { + let sinks = metadata_sinks.clone(); + async move { + let params: ReverseMetadataParams = + serde_json::from_value(params).map_err(invalid_reverse_params)?; + let generation = params.generation()?; + let sink = reverse_sink(&sinks, &generation).await?; + sink.metadata(HookFunctionReverseMetadata { + generation, + execution_id: params.execution_id, + title: params.title.unwrap_or_default(), + metadata: params.metadata.unwrap_or_default(), + }) + .await + .map_err(reverse_port_error)?; + Ok(json!({})) + } + }) + .await + .map_err(|error| map_invocation_error(error, false))?; + + let ask_sinks = self.reverse_sinks.clone(); + self.client + .register_handler("backend.tool.ask", move |params| { + let sinks = ask_sinks.clone(); + async move { + let params: ReverseAskParams = + serde_json::from_value(params).map_err(invalid_reverse_params)?; + let generation = params.generation()?; + let sink = reverse_sink(&sinks, &generation).await?; + match sink + .ask(HookFunctionReverseAsk { + generation, + execution_id: params.execution_id, + permission: params.permission, + patterns: params.patterns, + always: params.always, + metadata: params.metadata, + }) + .await + .map_err(reverse_port_error)? + { + HookFunctionReverseReply::Once | HookFunctionReverseReply::Always => { + Ok(json!({})) + } + HookFunctionReverseReply::Reject { feedback } => Err(RpcHandlerError { + code: -32003, + message: feedback.unwrap_or_else(|| "permission denied".to_string()), + data: None, + }), + } + } + }) + .await + .map_err(|error| map_invocation_error(error, false))?; + + *registered = true; + Ok(()) + } +} + +pub fn hook_function_runtime(client: PluginHostClient) -> Arc { + Arc::new(OpenCodeHookFunctionRuntime::new(client)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginIdentity { + id: Option, + spec: String, + entry: String, + index: usize, +} + +impl From for HookFunctionPluginIdentity { + fn from(value: RawPluginIdentity) -> Self { + Self { + id: value.id, + spec: value.spec, + entry: value.entry, + index: value.index, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawConfigContributor { + plugin: RawPluginIdentity, + outcome: bitfun_runtime_ports::HookFunctionContributorOutcome, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawConfigContribution { + plugin: RawPluginIdentity, + outcome: bitfun_runtime_ports::HookFunctionContributorOutcome, + config: serde_json::Map, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawToolRegistration { + #[serde(rename = "registrationID")] + registration_id: String, + id: String, + plugin: Option, + description: String, + parameters: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawOpenInstanceResult { + #[serde(rename = "instanceID")] + instance_id: String, + generation_key: String, + revision: String, + config: serde_json::Map, + config_contributors: Vec, + config_contributions: Vec, + diagnostics: Vec, + hooks: Vec, + tools: Vec, + #[serde(rename = "auth")] + _auth: Vec, + #[serde(rename = "providers")] + _providers: Vec, + #[serde(rename = "workspaces")] + _workspaces: Vec, + #[serde(rename = "gatewayURL")] + _gateway_url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginPreparation { + review_digest: String, + reviewed: Vec, + prepared: Vec, + failed: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPreparedPlugin { + identity: String, + content_hash: Option, +} + +impl RawPluginPreparation { + fn into_summary(self) -> PluginPreparationSummary { + let content_digests = self + .prepared + .iter() + .filter_map(|plugin| { + plugin + .content_hash + .as_ref() + .map(|digest| (plugin.identity.clone(), digest.clone())) + }) + .collect::>(); + PluginPreparationSummary { + review_digest: self.review_digest, + reviewed_count: self.reviewed.len(), + prepared_count: self.prepared.len(), + failed_count: self.failed.len(), + content_digests, + } + } +} + +impl RawOpenInstanceResult { + fn into_batch( + self, + expected: &HookFunctionGeneration, + ) -> PortResult { + if self.instance_id != expected.instance_id + || self.generation_key != expected.generation_key + || self.revision != expected.revision + { + return Err(PortError::new( + PortErrorKind::Backend, + "plugin host open result generation lease does not match the request", + )); + } + let hooks = self + .hooks + .into_iter() + .filter_map(|hook| match hook.as_str() { + "tool.execute.before" => Some(HookFunctionHookKind::ToolExecuteBefore), + "tool.execute.after" => Some(HookFunctionHookKind::ToolExecuteAfter), + _ => None, + }) + .collect(); + Ok(HookFunctionRegistrationBatch { + generation: expected.clone(), + config: self.config, + config_contributors: self + .config_contributors + .into_iter() + .map(|entry| HookFunctionConfigContributor { + plugin: entry.plugin.into(), + outcome: entry.outcome, + }) + .collect(), + config_contributions: self + .config_contributions + .into_iter() + .map(|entry| HookFunctionConfigContribution { + plugin: entry.plugin.into(), + outcome: entry.outcome, + config: entry.config, + }) + .collect(), + diagnostics: self.diagnostics, + hooks, + tools: self + .tools + .into_iter() + .map(|tool| HookFunctionToolRegistration { + registration_id: tool.registration_id, + id: tool.id, + plugin: tool.plugin.map(Into::into), + description: tool.description, + parameters: tool.parameters, + }) + .collect(), + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReverseMetadataParams { + #[serde(rename = "instanceID")] + instance_id: String, + generation_key: Option, + revision: Option, + #[serde(rename = "executionID")] + execution_id: String, + title: Option, + metadata: Option>, +} + +impl ReverseMetadataParams { + fn generation(&self) -> Result { + reverse_generation(&self.instance_id, &self.generation_key, &self.revision) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReverseAskParams { + #[serde(rename = "instanceID")] + instance_id: String, + generation_key: Option, + revision: Option, + #[serde(rename = "executionID")] + execution_id: String, + permission: String, + patterns: Vec, + always: Vec, + metadata: serde_json::Map, +} + +impl ReverseAskParams { + fn generation(&self) -> Result { + reverse_generation(&self.instance_id, &self.generation_key, &self.revision) + } +} + +fn reverse_generation( + instance_id: &str, + generation_key: &Option, + revision: &Option, +) -> Result { + Ok(HookFunctionGeneration { + instance_id: instance_id.to_string(), + generation_key: generation_key.clone().ok_or_else(|| { + RpcHandlerError::new( + -32602, + "generationKey is required for plugin tool callbacks", + ) + })?, + revision: revision.clone().ok_or_else(|| { + RpcHandlerError::new(-32602, "revision is required for plugin tool callbacks") + })?, + }) +} + +async fn reverse_sink( + sinks: &RwLock>>, + generation: &HookFunctionGeneration, +) -> Result, RpcHandlerError> { + sinks + .read() + .await + .get(&generation_key(generation)) + .cloned() + .ok_or_else(|| RpcHandlerError::new(-32004, "plugin generation is no longer active")) +} + +fn invalid_reverse_params(error: serde_json::Error) -> RpcHandlerError { + RpcHandlerError::new( + -32602, + format!("invalid plugin tool callback params: {error}"), + ) +} + +fn reverse_port_error(error: PortError) -> RpcHandlerError { + let code = if error.kind == PortErrorKind::PermissionDenied { + -32003 + } else { + -32603 + }; + RpcHandlerError::new(code, error.to_string()) +} + +fn generation_key(generation: &HookFunctionGeneration) -> RuntimeGenerationKey { + ( + generation.instance_id.clone(), + generation.generation_key.clone(), + generation.revision.clone(), + ) +} + +fn lease(generation: &HookFunctionGeneration) -> PluginGenerationLease { + PluginGenerationLease { + instance_id: generation.instance_id.clone(), + generation_key: generation.generation_key.clone(), + revision: generation.revision.clone(), + } +} + +fn map_invocation_error( + error: PluginHostError, + side_effect: bool, +) -> bitfun_runtime_ports::PortError { + use bitfun_runtime_ports::{PortError, PortErrorKind}; + let kind = match error { + // The tool may have completed before cancellation was observed, while + // its authoritative response was lost at the deadline. Retrying can + // duplicate side effects even when cancellation is later confirmed. + PluginHostError::ToolStoppedAfterTimeout { .. } => PortErrorKind::OutcomeUnknown, + PluginHostError::ToolOutcomeUnknown { .. } => PortErrorKind::OutcomeUnknown, + PluginHostError::RequestTimeout { .. } if side_effect => PortErrorKind::OutcomeUnknown, + PluginHostError::RequestTimeout { .. } => PortErrorKind::Timeout, + PluginHostError::ShuttingDown => PortErrorKind::CleanupRequired, + PluginHostError::ConnectionClosed(_) => PortErrorKind::OutcomeUnknown, + PluginHostError::Rpc { code, .. } if code == -32003 => PortErrorKind::PermissionDenied, + PluginHostError::Rpc { .. } => PortErrorKind::Backend, + _ => PortErrorKind::Backend, + }; + PortError::new(kind, error.to_string()) +} + +#[async_trait::async_trait] +impl HookFunctionRuntime for OpenCodeHookFunctionRuntime { + fn availability(&self) -> HookFunctionAvailability { + if self.client.is_closed() { + HookFunctionAvailability::Unavailable { + reason: "plugin host connection is closed".to_string(), + } + } else { + HookFunctionAvailability::Available + } + } + + async fn start( + &self, + request: HookFunctionStartRequest, + registrations: Arc, + reverse: Arc, + deadline: Duration, + ) -> PortResult { + self.ensure_reverse_handlers().await?; + self.reverse_sinks + .write() + .await + .insert(generation_key(&request.generation), reverse); + let generation = request.generation.clone(); + let plugins = request + .plugins + .into_iter() + .map( + |plugin: HookFunctionPluginDeclaration| crate::PluginDeclaration { + spec: plugin.spec, + options: plugin.options, + base_directory: plugin.base_directory, + }, + ) + .collect(); + let result = self + .client + .open_instance( + PluginInstanceOpenRequest { + instance_id: generation.instance_id.clone(), + generation_key: generation.generation_key.clone(), + revision: generation.revision.clone(), + project: json!({ + "id": request.project_id, + "worktree": request.project_worktree, + "time": {"created": request.project_created_at_ms}, + }), + config: request.config, + directory: request.directory, + worktree: request.worktree, + plugins, + configuration_fingerprint: request.configuration_fingerprint, + expected_content_digests: (!request.expected_content_digests.is_empty()) + .then_some(request.expected_content_digests), + expected_review_digest: request.expected_review_digest, + }, + deadline, + ) + .await + .map_err(|error| map_invocation_error(error, false)); + let result = match result { + Ok(result) => result, + Err(error) => { + self.reverse_sinks + .write() + .await + .remove(&generation_key(&generation)); + return Err(error); + } + }; + let raw: RawOpenInstanceResult = serde_json::from_value(result).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("plugin host open result is invalid: {error}"), + ) + })?; + let batch = raw.into_batch(&generation)?; + if let Err(error) = registrations.publish_generation(batch).await { + self.reverse_sinks + .write() + .await + .remove(&generation_key(&generation)); + let _ = self + .client + .close_instance(&generation.instance_id, deadline) + .await; + return Err(error); + } + Ok(generation) + } + + async fn transform_tool_before( + &self, + request: HookFunctionBeforeRequest, + deadline: Duration, + ) -> PortResult { + let result = self + .client + .call_hook( + &lease(&request.generation), + "tool.execute.before", + json!({ + "tool": request.tool_name, + "sessionID": request.session_id, + "callID": request.call_id, + }), + json!({"args": request.args}), + deadline, + ) + .await; + let result = match result { + Ok(result) => result, + Err(error @ PluginHostError::RequestTimeout { .. }) => { + self.client + .close("plugin before-hook deadline exceeded".to_string()) + .await; + return Err(map_invocation_error(error, false)); + } + Err(error) => return Err(map_invocation_error(error, false)), + }; + let args = result + .get("output") + .and_then(|output| output.get("args")) + .cloned() + .ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + "tool.execute.before response is missing output.args", + ) + })?; + Ok(HookFunctionBeforeResult { args }) + } + + async fn execute_tool( + &self, + request: HookFunctionToolRequest, + deadline: Duration, + ) -> PortResult { + let result = self + .client + .execute_tool( + &lease(&request.generation), + &request.execution_id, + &request.registration_id, + request.args, + serde_json::to_value(request.context).map_err(|error| { + PortError::new( + PortErrorKind::InvalidRequest, + format!("plugin tool context is invalid: {error}"), + ) + })?, + deadline, + ) + .await; + let result = match result { + Ok(result) => result, + Err(PluginHostError::RequestTimeout { .. }) => { + const CANCEL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); + let cancellation = self + .client + .cancel_tool( + &lease(&request.generation), + &request.execution_id, + Some("deadline_exceeded"), + CANCEL_CONFIRM_TIMEOUT, + ) + .await; + let reason = match cancellation { + Ok(result) + if result.get("cancelled").and_then(Value::as_bool) == Some(true) => + { + "plugin tool response timed out and cancellation was confirmed".to_string() + } + Ok(_) => "plugin host did not confirm cancellation after the tool deadline" + .to_string(), + Err(error) => { + format!("plugin host cancellation failed after the tool deadline: {error}") + } + }; + self.client.close(reason.clone()).await; + return Err(map_invocation_error( + PluginHostError::ToolOutcomeUnknown { + execution_id: request.execution_id, + reason, + }, + true, + )); + } + Err(error) => return Err(map_invocation_error(error, true)), + }; + let attachments = result + .get("attachments") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|attachment| { + Ok(HookFunctionToolAttachment { + mime: attachment + .get("mime") + .and_then(Value::as_str) + .ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + "plugin tool attachment is missing mime", + ) + })? + .to_string(), + url: attachment + .get("url") + .and_then(Value::as_str) + .ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + "plugin tool attachment is missing url", + ) + })? + .to_string(), + filename: attachment + .get("filename") + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .collect::>>()?; + Ok(HookFunctionToolResult { + output: result, + attachments, + }) + } + + async fn transform_tool_after( + &self, + request: HookFunctionAfterRequest, + deadline: Duration, + ) -> PortResult { + let result = self + .client + .call_hook( + &lease(&request.generation), + "tool.execute.after", + json!({ + "tool": request.tool_name, + "sessionID": request.session_id, + "callID": request.call_id, + "args": request.args, + }), + serde_json::to_value(request.output).map_err(|error| { + PortError::new( + PortErrorKind::InvalidRequest, + format!("plugin after-hook output is invalid: {error}"), + ) + })?, + deadline, + ) + .await; + let result = match result { + Ok(result) => result, + Err(error @ PluginHostError::RequestTimeout { .. }) => { + self.client + .close("plugin after-hook deadline exceeded".to_string()) + .await; + return Err(map_invocation_error(error, false)); + } + Err(error) => return Err(map_invocation_error(error, false)), + }; + serde_json::from_value(result.get("output").cloned().ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + "tool.execute.after response is missing output", + ) + })?) + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("tool.execute.after response is invalid: {error}"), + ) + }) + } + + async fn cancel( + &self, + request: HookFunctionCancelRequest, + deadline: Duration, + ) -> PortResult { + let result = self + .client + .cancel_tool( + &lease(&request.generation), + &request.execution_id, + request.reason.as_deref(), + deadline, + ) + .await + .map_err(|error| map_invocation_error(error, true))?; + Ok(HookFunctionCancelResult { + stopped: result + .get("cancelled") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + } + + async fn dispose( + &self, + request: HookFunctionDisposeRequest, + deadline: Duration, + ) -> PortResult { + let closed = self + .client + .close_instance(&request.generation.instance_id, deadline) + .await + .map_err(|error| map_invocation_error(error, true))?; + if closed { + self.reverse_sinks + .write() + .await + .remove(&generation_key(&request.generation)); + } + Ok(HookFunctionDisposeResult { closed }) + } +} + impl PluginHostClient { pub fn generation(&self) -> u64 { self.state.generation @@ -51,6 +794,10 @@ impl PluginHostClient { self.state.closed.load(Ordering::Acquire) } + pub fn capabilities(&self) -> &PluginHostCapabilities { + &self.state.capabilities + } + pub async fn set_log_level(&self, level: &str) -> Result<(), PluginHostError> { let result = self .request( @@ -81,10 +828,19 @@ impl PluginHostClient { &self, request: PluginPrepareRequest, deadline: Duration, - ) -> Result { + ) -> Result { let params = serde_json::to_value(request) .map_err(|error| PluginHostError::Protocol(error.to_string()))?; - self.request("host.plugins.prepare", params, deadline).await + let result = self + .request("host.plugins.prepare", params, deadline) + .await?; + serde_json::from_value::(result) + .map(RawPluginPreparation::into_summary) + .map_err(|error| { + PluginHostError::Protocol(format!( + "host.plugins.prepare returned an invalid result: {error}" + )) + }) } pub async fn close_instance( @@ -109,6 +865,91 @@ impl PluginHostClient { }) } + pub async fn call_hook( + &self, + lease: &PluginGenerationLease, + hook: &str, + input: Value, + output: Value, + deadline: Duration, + ) -> Result { + self.require_generation_fencing()?; + let result = self + .request( + "host.hook.call", + json!({ + "instanceID": lease.instance_id, + "generationKey": lease.generation_key, + "revision": lease.revision, + "hook": hook, + "input": input, + "output": output, + }), + deadline, + ) + .await?; + validate_fenced_response(&result, lease, None)?; + if result.get("hook").and_then(Value::as_str) != Some(hook) { + return Err(PluginHostError::Protocol( + "host.hook.call response returned a mismatched hook name".to_string(), + )); + } + Ok(result) + } + + pub async fn execute_tool( + &self, + lease: &PluginGenerationLease, + execution_id: &str, + registration_id: &str, + args: Value, + context: Value, + deadline: Duration, + ) -> Result { + self.require_generation_fencing()?; + let result = self + .request( + "host.tool.execute", + json!({ + "instanceID": lease.instance_id, + "generationKey": lease.generation_key, + "revision": lease.revision, + "executionID": execution_id, + "registrationID": registration_id, + "args": args, + "context": context, + }), + deadline, + ) + .await?; + validate_fenced_response(&result, lease, Some(execution_id))?; + result.get("result").cloned().ok_or_else(|| { + PluginHostError::Protocol("host.tool.execute response is missing result".to_string()) + }) + } + + pub async fn cancel_tool( + &self, + lease: &PluginGenerationLease, + execution_id: &str, + reason: Option<&str>, + deadline: Duration, + ) -> Result { + self.require_generation_fencing()?; + self.request( + "host.tool.cancel", + json!({ + "instanceID": lease.instance_id, + "generationKey": lease.generation_key, + "revision": lease.revision, + "executionID": execution_id, + "reason": reason, + }), + deadline, + ) + .await + } + pub async fn request( &self, method: &str, @@ -141,6 +982,10 @@ impl PluginHostClient { self.state .register_pending(request_id.clone(), sender, allow_during_shutdown) .await?; + let _pending_guard = PendingRequestGuard { + state: self.state.clone(), + request_id: request_id.clone(), + }; log::debug!( "Plugin host RPC request sending: generation={}, request_id={}, method={}", self.state.generation, @@ -239,7 +1084,7 @@ impl PluginHostClient { self.state.close(reason.into()).await; } - pub async fn register_handler( + pub(crate) async fn register_handler( &self, method: &str, handler: F, @@ -258,6 +1103,36 @@ impl PluginHostClient { ); Ok(()) } + + fn require_generation_fencing(&self) -> Result<(), PluginHostError> { + if self.capabilities().supports(GENERATION_FENCING_V1) { + Ok(()) + } else { + Err(PluginHostError::Protocol( + "plugin host did not negotiate generation-fencing-v1".to_string(), + )) + } + } +} + +fn validate_fenced_response( + result: &Value, + lease: &PluginGenerationLease, + execution_id: Option<&str>, +) -> Result<(), PluginHostError> { + let identity_matches = result.get("instanceID").and_then(Value::as_str) + == Some(lease.instance_id.as_str()) + && result.get("generationKey").and_then(Value::as_str) + == Some(lease.generation_key.as_str()) + && result.get("revision").and_then(Value::as_str) == Some(lease.revision.as_str()); + let execution_matches = execution_id + .is_none_or(|expected| result.get("executionID").and_then(Value::as_str) == Some(expected)); + if identity_matches && execution_matches { + return Ok(()); + } + Err(PluginHostError::Protocol( + "plugin host response generation lease does not match the request".to_string(), + )) } pub struct JsonRpcPeer { @@ -266,9 +1141,24 @@ pub struct JsonRpcPeer { impl JsonRpcPeer { pub fn start(stream: TcpStream, generation: u64, max_frame_bytes: usize) -> Self { + Self::start_with_capabilities( + stream, + generation, + max_frame_bytes, + PluginHostCapabilities::default(), + ) + } + + pub fn start_with_capabilities( + stream: TcpStream, + generation: u64, + max_frame_bytes: usize, + capabilities: PluginHostCapabilities, + ) -> Self { let (outbound, receiver) = mpsc::channel(OUTBOUND_CAPACITY); let state = Arc::new(PeerState { generation, + capabilities, max_frame_bytes, sequence: AtomicU64::new(0), admission: Mutex::new(()), @@ -296,6 +1186,7 @@ impl JsonRpcPeer { pub(super) struct PeerState { pub(super) generation: u64, + pub(super) capabilities: PluginHostCapabilities, pub(super) max_frame_bytes: usize, pub(super) sequence: AtomicU64, pub(super) admission: Mutex<()>, diff --git a/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs b/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs index bdcf33681b..dfb7af7c15 100644 --- a/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs +++ b/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs @@ -14,7 +14,7 @@ const DEFAULT_MAX_TOTAL_BYTES: usize = 32 * 1024 * 1024; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct StreamReadParams { +pub(crate) struct StreamReadParams { #[serde(rename = "instanceID")] pub instance_id: String, #[serde(rename = "streamID")] @@ -24,28 +24,29 @@ pub struct StreamReadParams { #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct StreamReadResult { +pub(crate) struct StreamReadResult { pub data: String, pub eof: bool, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct StreamCancelParams { +pub(crate) struct StreamCancelParams { #[serde(rename = "instanceID")] pub instance_id: String, #[serde(rename = "streamID")] pub stream_id: String, - pub reason: Option, + #[serde(rename = "reason")] + pub _reason: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct StreamCancelResult { +pub(crate) struct StreamCancelResult { pub cancelled: bool, } #[derive(Debug, Error, Clone, PartialEq, Eq)] -pub enum StreamRegistryError { +pub(crate) enum StreamRegistryError { #[error("response stream registry capacity was reached")] Capacity, #[error("response stream body exceeds the registry byte limit")] @@ -57,7 +58,7 @@ pub enum StreamRegistryError { } #[derive(Clone)] -pub struct PluginHostStreamRegistry { +pub(crate) struct PluginHostStreamRegistry { state: Arc>, sequence: Arc, changed: Arc, @@ -83,7 +84,7 @@ impl Default for PluginHostStreamRegistry { } impl PluginHostStreamRegistry { - pub fn with_limits(max_active_streams: usize, max_total_bytes: usize) -> Self { + pub(crate) fn with_limits(max_active_streams: usize, max_total_bytes: usize) -> Self { Self { state: Arc::new(Mutex::new(StreamRegistryState { streams: HashMap::new(), @@ -96,7 +97,7 @@ impl PluginHostStreamRegistry { } } - pub async fn add( + pub(crate) async fn add( &self, instance_id: &str, bytes: Vec, @@ -128,7 +129,7 @@ impl PluginHostStreamRegistry { }) } - pub async fn read( + pub(crate) async fn read( &self, params: StreamReadParams, ) -> Result { @@ -164,7 +165,7 @@ impl PluginHostStreamRegistry { Ok(StreamReadResult { data, eof }) } - pub async fn cancel( + pub(crate) async fn cancel( &self, params: StreamCancelParams, ) -> Result { @@ -184,7 +185,7 @@ impl PluginHostStreamRegistry { Ok(StreamCancelResult { cancelled: true }) } - pub async fn cancel_instance(&self, instance_id: &str) -> usize { + pub(crate) async fn cancel_instance(&self, instance_id: &str) -> usize { let mut state = self.state.lock().await; let stream_ids = state .streams @@ -203,7 +204,7 @@ impl PluginHostStreamRegistry { stream_ids.len() } - pub async fn cancel_all(&self) -> usize { + pub(crate) async fn cancel_all(&self) -> usize { let mut state = self.state.lock().await; let count = state.streams.len(); state.streams.clear(); @@ -214,11 +215,11 @@ impl PluginHostStreamRegistry { count } - pub async fn active_count(&self) -> usize { + pub(crate) async fn active_count(&self) -> usize { self.state.lock().await.streams.len() } - pub async fn wait_until_empty(&self, timeout: Duration) -> bool { + pub(crate) async fn wait_until_empty(&self, timeout: Duration) -> bool { let wait = async { loop { let changed = self.changed.notified(); @@ -292,7 +293,7 @@ mod tests { .cancel(StreamCancelParams { instance_id: "instance:1".to_string(), stream_id: descriptor.stream_id, - reason: Some("test".to_string()), + _reason: Some("test".to_string()), }) .await .expect("cancel") @@ -319,7 +320,7 @@ mod tests { .cancel(StreamCancelParams { instance_id: "instance:1".to_string(), stream_id: descriptor.stream_id, - reason: Some("test".to_string()), + _reason: Some("test".to_string()), }) .await .expect("cancel"); diff --git a/src/crates/adapters/opencode-plugin-host/src/tests.rs b/src/crates/adapters/opencode-plugin-host/src/tests.rs index 8c71d21b90..7c48d2b2de 100644 --- a/src/crates/adapters/opencode-plugin-host/src/tests.rs +++ b/src/crates/adapters/opencode-plugin-host/src/tests.rs @@ -50,7 +50,8 @@ async fn handshake_accepts_matching_token_and_returns_cache_directory() { "token": "test-token", "protocolVersion": 1, "opencodeVersion": "1.17.18", - "maxFrameBytes": DEFAULT_MAX_FRAME_BYTES + "maxFrameBytes": DEFAULT_MAX_FRAME_BYTES, + "capabilities": ["config-contributors-v1", "config-contributions-v2", "generation-fencing-v1", "unknown-v1"] } }), DEFAULT_MAX_FRAME_BYTES, @@ -71,7 +72,11 @@ async fn handshake_accepts_matching_token_and_returns_cache_directory() { .expect("matching handshake should succeed"); let response = host.await.expect("fake host task should finish"); - assert_eq!(negotiated, DEFAULT_MAX_FRAME_BYTES); + assert_eq!(negotiated.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); + assert!(negotiated.capabilities.supports("config-contributors-v1")); + assert!(negotiated.capabilities.supports("config-contributions-v2")); + assert!(negotiated.capabilities.supports("generation-fencing-v1")); + assert!(!negotiated.capabilities.supports("unknown-v1")); assert_eq!( response["result"]["cacheDirectory"], expected_cache_directory @@ -204,7 +209,7 @@ async fn handshake_clamps_requested_frame_limit_to_the_safe_range() { .await .expect("handshake should succeed"); host.await.expect("fake host should finish"); - negotiated + negotiated.max_frame_bytes } assert_eq!(negotiate(1).await, MIN_NEGOTIATED_FRAME_BYTES); @@ -352,7 +357,6 @@ async fn plugin_host_shutdown_waits_for_rpc_response_and_process_exit() { let report = host.shutdown(PluginHostShutdownPolicy::default()).await; assert_eq!(report.disposition, PluginHostShutdownDisposition::Graceful); - assert!(report.reaped); assert!(report.rpc_completed); assert_eq!(report.exit_code, Some(0)); assert!( @@ -437,7 +441,6 @@ async fn plugin_host_shutdown_forces_a_host_that_ignores_shutdown_and_eof() { let report = host.shutdown(policy).await; assert_eq!(report.disposition, PluginHostShutdownDisposition::Forced); - assert!(report.reaped); assert!(!report.rpc_completed); assert!(report.duration_ms < 2_000); } diff --git a/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs b/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs index 2bd189e1c0..9d3a6b56dc 100644 --- a/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs +++ b/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs @@ -1,12 +1,292 @@ use crate::{ - read_frame, read_host_stream, write_frame, HostStreamReadError, JsonRpcPeer, PluginDeclaration, - PluginHostError, PluginInstanceOpenRequest, PluginPrepareRequest, StreamDescriptor, - DEFAULT_MAX_FRAME_BYTES, + hook_function_runtime, read_frame, read_host_stream, register_backend_handlers, write_frame, + BackendDiagnosticError, BackendDiagnosticEvent, BackendRouteFailure, BackendRouteRequest, + HostStreamReadError, JsonRpcPeer, OpenCodeBackendHandler, PluginDeclaration, PluginHostError, + PluginInstanceOpenRequest, PluginPrepareRequest, StreamDescriptor, DEFAULT_MAX_FRAME_BYTES, +}; +use async_trait::async_trait; +use bitfun_runtime_ports::{ + HookFunctionGeneration, HookFunctionRegistrationBatch, HookFunctionRegistrationSink, + HookFunctionReverseAsk, HookFunctionReverseMetadata, HookFunctionReverseReply, + HookFunctionReverseSink, HookFunctionStartRequest, HookFunctionToolContext, + HookFunctionToolRequest, PortErrorKind, PortResult, }; use serde_json::json; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::net::{TcpListener, TcpStream}; +#[derive(Default)] +struct CapturingRegistrationSink { + batches: Mutex>, +} + +#[async_trait] +impl HookFunctionRegistrationSink for CapturingRegistrationSink { + async fn publish_generation(&self, batch: HookFunctionRegistrationBatch) -> PortResult<()> { + self.batches.lock().expect("batch lock").push(batch); + Ok(()) + } +} + +struct UnusedReverseSink; + +struct BlockingBackend { + started: Arc, + dropped: Arc, +} + +struct DropFlag(Arc); + +impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +#[async_trait] +impl OpenCodeBackendHandler for BlockingBackend { + async fn handle_route( + &self, + _request: BackendRouteRequest, + ) -> Result { + let _drop_flag = DropFlag(self.dropped.clone()); + self.started.notify_one(); + std::future::pending().await + } + + async fn publish_diagnostic( + &self, + _event: BackendDiagnosticEvent, + ) -> Result<(), BackendDiagnosticError> { + Ok(()) + } +} + +#[async_trait] +impl HookFunctionReverseSink for UnusedReverseSink { + async fn metadata(&self, _update: HookFunctionReverseMetadata) -> PortResult<()> { + Ok(()) + } + + async fn ask(&self, _request: HookFunctionReverseAsk) -> PortResult { + Ok(HookFunctionReverseReply::Once) + } +} + +#[tokio::test] +async fn hook_runtime_start_publishes_a_typed_complete_generation() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start_with_capabilities( + backend_stream, + 21, + DEFAULT_MAX_FRAME_BYTES, + crate::PluginHostCapabilities::all_supported(), + ); + let runtime = hook_function_runtime(peer.client()); + let host = tokio::spawn(async move { + let request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("instance open request should be readable"); + assert_eq!(request["method"], "host.instance.open"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "instanceID": "instance-a", + "generationKey": "generation-a", + "revision": "revision-a", + "config": {"model": "fixture"}, + "configContributors": [], + "configContributions": [], + "diagnostics": [], + "hooks": ["tool.execute.before", "tool.execute.after"], + "tools": [{ + "registrationID": "registration-a", + "id": "echo", + "description": "Echo input", + "parameters": {"type": "object"} + }], + "auth": [], + "providers": [], + "workspaces": [], + "gatewayURL": "http://127.0.0.1:1234/" + } + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("instance open response should be written"); + }); + let generation = HookFunctionGeneration { + instance_id: "instance-a".to_string(), + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + }; + let registrations = Arc::new(CapturingRegistrationSink::default()); + + let started = runtime + .start( + HookFunctionStartRequest { + generation: generation.clone(), + project_id: "project-a".to_string(), + project_worktree: "C:/workspace".to_string(), + project_created_at_ms: 42, + config: serde_json::Map::new(), + directory: "C:/workspace".to_string(), + worktree: "C:/workspace".to_string(), + plugins: Vec::new(), + configuration_fingerprint: None, + expected_content_digests: BTreeMap::new(), + expected_review_digest: None, + }, + registrations.clone(), + Arc::new(UnusedReverseSink), + Duration::from_secs(1), + ) + .await + .expect("typed generation should start"); + + assert_eq!(started, generation); + let batches = registrations.batches.lock().expect("batch lock"); + assert_eq!(batches[0].generation, generation); + assert_eq!(batches[0].tools[0].id, "echo"); + assert_eq!(batches[0].hooks.len(), 2); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn hook_runtime_tool_timeout_is_outcome_unknown_even_when_cancel_is_confirmed() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start_with_capabilities( + backend_stream, + 22, + DEFAULT_MAX_FRAME_BYTES, + crate::PluginHostCapabilities::all_supported(), + ); + let runtime = hook_function_runtime(peer.client()); + let host = tokio::spawn(async move { + let execute = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("tool execute request should be readable"); + assert_eq!(execute["method"], "host.tool.execute"); + + let cancel = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("tool cancel request should be readable"); + assert_eq!(cancel["method"], "host.tool.cancel"); + assert_eq!(cancel["params"]["executionID"], "execution-a"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": cancel["id"], + "result": {"cancelled": true} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("tool cancel response should be written"); + }); + + let error = runtime + .execute_tool( + HookFunctionToolRequest { + generation: HookFunctionGeneration { + instance_id: "instance-a".to_string(), + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + }, + execution_id: "execution-a".to_string(), + registration_id: "registration-a".to_string(), + args: json!({"value": 1}), + context: HookFunctionToolContext { + session_id: "session-a".to_string(), + message_id: "message-a".to_string(), + agent: "agentic".to_string(), + call_id: Some("call-a".to_string()), + }, + }, + Duration::from_millis(20), + ) + .await + .expect_err("a dispatched tool timeout must not claim a definite cancellation"); + + assert_eq!(error.kind, PortErrorKind::OutcomeUnknown); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn backend_drain_cancels_and_joins_an_admitted_route() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 23, DEFAULT_MAX_FRAME_BYTES); + let started = Arc::new(tokio::sync::Notify::new()); + let dropped = Arc::new(AtomicBool::new(false)); + let bridge = register_backend_handlers( + peer.client(), + Arc::new(BlockingBackend { + started: started.clone(), + dropped: dropped.clone(), + }), + ) + .await + .expect("backend handlers should register"); + let host = tokio::spawn(async move { + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": "host:route:1", + "method": "backend.http.request", + "params": { + "instanceID": "instance-a", + "requestID": "request-a", + "method": "GET", + "path": "/project", + "headers": [], + "body": null + } + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("backend route request should be written"); + read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("drained route should receive an RPC response") + }); + + started.notified().await; + assert!(bridge.begin_draining().await); + assert!(dropped.load(Ordering::Acquire)); + let response = host.await.expect("fake host should finish"); + assert_eq!(response["error"]["code"], -32000); +} + +#[tokio::test] +async fn backend_handlers_do_not_retain_a_dropped_bridge() { + let (backend_stream, _host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 24, DEFAULT_MAX_FRAME_BYTES); + let bridge = register_backend_handlers( + peer.client(), + Arc::new(BlockingBackend { + started: Arc::new(tokio::sync::Notify::new()), + dropped: Arc::new(AtomicBool::new(false)), + }), + ) + .await + .expect("backend handlers should register"); + let weak = Arc::downgrade(&bridge); + + drop(bridge); + + assert!(weak.upgrade().is_none()); +} + #[tokio::test] async fn peer_correlates_out_of_order_responses_by_request_id() { let (backend_stream, mut host_stream) = connected_streams().await; @@ -119,7 +399,11 @@ async fn client_opens_a_typed_plugin_instance() { &json!({ "jsonrpc": "2.0", "id": request["id"], - "result": {"instanceID": "bitfun:test-instance"} + "result": { + "instanceID": "bitfun:test-instance", + "generationKey": "generation-fixture", + "revision": "revision-fixture" + } }), DEFAULT_MAX_FRAME_BYTES, ) @@ -131,6 +415,8 @@ async fn client_opens_a_typed_plugin_instance() { .open_instance( PluginInstanceOpenRequest { instance_id: "bitfun:test-instance".to_string(), + generation_key: "generation-fixture".to_string(), + revision: "revision-fixture".to_string(), project: json!({"id": "project", "worktree": "C:/workspace"}), config: serde_json::Map::new(), directory: "C:/workspace".to_string(), @@ -141,6 +427,8 @@ async fn client_opens_a_typed_plugin_instance() { base_directory: None, }], configuration_fingerprint: Some("fixture-open".to_string()), + expected_content_digests: None, + expected_review_digest: None, }, Duration::from_secs(1), ) @@ -171,7 +459,28 @@ async fn client_prepares_typed_plugins() { &json!({ "jsonrpc": "2.0", "id": request["id"], - "result": {"prepared": [], "failed": [], "diagnostics": []} + "result": { + "reviewDigest": "0".repeat(64), + "reviewed": [{ + "spec": "bitfun-demo-echo", + "source": "npm", + "identity": "npm:bitfun-demo-echo", + "canonicalSource": "bitfun-demo-echo", + "baseDirectory": "C:/workspace", + "optionsDigest": "1".repeat(64) + }], + "prepared": [{ + "spec": "bitfun-demo-echo", + "identity": "npm:bitfun-demo-echo", + "source": "npm", + "target": "bitfun-demo-echo", + "entry": "C:/cache/bitfun-demo-echo/index.js", + "cache": "hit", + "contentHash": "2".repeat(64) + }], + "failed": [], + "diagnostics": [] + } }), DEFAULT_MAX_FRAME_BYTES, ) @@ -189,13 +498,20 @@ async fn client_prepares_typed_plugins() { }], configuration_fingerprint: Some("fixture-prewarm".to_string()), default_base_directory: None, + allow_install: Some(false), }, Duration::from_secs(1), ) .await .expect("plugin prepare should resolve"); - assert_eq!(result["prepared"], json!([])); + assert_eq!(result.review_digest, "0".repeat(64)); + assert_eq!(result.reviewed_count, 1); + assert_eq!(result.failed_count, 0); + assert_eq!( + result.content_digests.get("npm:bitfun-demo-echo"), + Some(&"2".repeat(64)) + ); host.await.expect("fake host should finish"); } diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index e86b8dbe6f..f0f52cff3c 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -383,6 +383,7 @@ opencode-plugin-host = [ "remote-connect", "git", "dep:bitfun-opencode-plugin-host", + "bitfun-runtime-ports/hook-function-runtime", ] ai-adapter-runtime = [ "dep:bitfun-ai-adapters", diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs index ec36963e6d..98782964bc 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs @@ -17,6 +17,7 @@ pub(crate) struct ExternalProvidedAgent { prompt: String, tools: Vec, permission_constraints: PermissionConstraintLayer, + temperature: Option, readonly: bool, behavior_version: String, } @@ -29,6 +30,7 @@ impl ExternalProvidedAgent { prompt: String, tools: Vec, permission_constraints: PermissionConstraintLayer, + temperature: Option, readonly: bool, behavior_version: String, ) -> Self { @@ -39,6 +41,7 @@ impl ExternalProvidedAgent { prompt, tools, permission_constraints, + temperature, readonly, behavior_version, } @@ -85,6 +88,10 @@ impl Agent for ExternalProvidedAgent { &self.permission_constraints } + fn model_temperature_override(&self) -> Option { + self.temperature + } + fn user_context_policy(&self) -> UserContextPolicy { default_custom_agent_user_context_policy(CustomAgentKind::Subagent) } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index f07504af85..8b40de112c 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -276,6 +276,13 @@ pub trait Agent: Send + Sync + 'static { true } + /// Optional model sampling temperature supplied by an external Agent + /// definition. The execution owner applies this to a per-turn client + /// clone; built-in Agents inherit the configured model temperature. + fn model_temperature_override(&self) -> Option { + None + } + /// Whether this agent is read-only (prevents file modifications) fn is_readonly(&self) -> bool { false diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index c04aa2f7c4..b21b402d60 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -56,6 +56,7 @@ impl ExternalSubagentModelBinding { pub struct ExternalSubagentRegistration { pub runtime_key: String, pub logical_id: String, + pub route_key: String, pub ecosystem_id: EcosystemId, pub provider_label: String, pub model_binding: ExternalSubagentModelBinding, @@ -96,6 +97,8 @@ struct ExternalSubagentGenerationEntry { pub(super) struct ExternalSubagentRegistryState { generations: RwLock>, workspace_routes: RwLock>>, + workspace_route_overlays: + RwLock>>>, } impl ExternalSubagentRegistryState { @@ -103,6 +106,7 @@ impl ExternalSubagentRegistryState { Self { generations: RwLock::new(HashMap::new()), workspace_routes: RwLock::new(HashMap::new()), + workspace_route_overlays: RwLock::new(HashMap::new()), } } @@ -140,18 +144,81 @@ impl ExternalSubagentRegistryState { .unwrap_or_else(std::sync::PoisonError::into_inner) } + fn read_route_overlays( + &self, + ) -> std::sync::RwLockReadGuard< + '_, + HashMap>>, + > { + self.workspace_route_overlays + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write_route_overlays( + &self, + ) -> std::sync::RwLockWriteGuard< + '_, + HashMap>>, + > { + self.workspace_route_overlays + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn effective_routes_for_workspace( + &self, + workspace_root: &Path, + ) -> BTreeMap { + let mut effective = self + .read_routes() + .get(workspace_root) + .cloned() + .unwrap_or_default(); + if let Some(overlays) = self.read_route_overlays().get(workspace_root) { + // Owner keys provide deterministic overlay precedence. The + // OpenCode Config owner is currently the only overlay publisher; + // unlike the base table, removing it reveals the latest route + // published by another external-source owner. + for routes in overlays.values() { + effective.extend(routes.clone()); + } + } + effective + } + pub(super) fn find_generation_entry(&self, runtime_key: &str) -> Option { self.read_generations() .get(runtime_key) .map(|entry| entry.agent_entry.clone()) } + /// Resolve a user-facing logical Agent id through the external route table + /// for one workspace. External generations are keyed by an opaque runtime + /// key, while sessions and product surfaces use the logical id. + pub(super) fn find_external_route_entry( + &self, + logical_id: &str, + workspace_root: &Path, + ) -> Option { + let workspace_root = canonical_local_workspace_path(workspace_root); + let logical_key = normalize_external_logical_id(logical_id); + let runtime_key = match self + .effective_routes_for_workspace(&workspace_root) + .get(&logical_key)? + { + ExternalSubagentRoute::External(runtime_key) => runtime_key.clone(), + ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => return None, + }; + self.find_generation_entry(&runtime_key) + } + pub(super) fn has_generation(&self, runtime_key: &str) -> bool { self.read_generations().contains_key(runtime_key) } fn prune_unrouted_generations(&self) { - let routed = self + let mut routed = self .read_routes() .values() .flat_map(BTreeMap::values) @@ -160,6 +227,16 @@ impl ExternalSubagentRegistryState { ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => None, }) .collect::>(); + routed.extend( + self.read_route_overlays() + .values() + .flat_map(BTreeMap::values) + .flat_map(BTreeMap::values) + .filter_map(|route| match route { + ExternalSubagentRoute::External(runtime_key) => Some(runtime_key.clone()), + ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => None, + }), + ); self.write_generations() .retain(|runtime_key, entry| entry.lease_count > 0 || routed.contains(runtime_key)); } @@ -212,6 +289,7 @@ impl ExternalSubagentRegistryState { let model_binding = entry.registration.model_binding.clone(); Some(ExternalPrimaryAgentTurnBinding { runtime_agent_key: runtime_key.to_string(), + route_key: Some(entry.registration.route_key.clone()), model_binding: Some(model_binding.clone()), route_owner: SessionAgentRouteOwner::External, lease: Some(ExternalSubagentGenerationLease { @@ -286,12 +364,22 @@ pub struct ExternalSubagentInvocationBinding { pub struct ExternalPrimaryAgentTurnBinding { pub runtime_agent_key: String, + pub route_key: Option, pub model_binding: Option, pub route_owner: SessionAgentRouteOwner, pub lease: Option, } impl AgentRegistry { + pub(super) fn find_external_route_entry( + &self, + logical_id: &str, + workspace_root: &Path, + ) -> Option { + self.external_subagents + .find_external_route_entry(logical_id, workspace_root) + } + /// Returns whether the logical id is owned by an external route in the /// requested workspace. `Unavailable` remains externally owned so a /// withdrawn candidate cannot expose a same-name local mutation path. @@ -300,7 +388,6 @@ impl AgentRegistry { logical_id: &str, workspace_root: Option<&Path>, ) -> bool { - let routes = self.external_subagents.read_routes(); let logical_key = normalize_external_logical_id(logical_id); let is_external = |route: &ExternalSubagentRoute| { matches!( @@ -310,9 +397,9 @@ impl AgentRegistry { }; workspace_root.is_some_and(|workspace| { let workspace = canonical_local_workspace_path(workspace); - routes - .get(&workspace) - .and_then(|workspace_routes| workspace_routes.get(&logical_key)) + self.external_subagents + .effective_routes_for_workspace(&workspace) + .get(&logical_key) .is_some_and(is_external) }) } @@ -322,6 +409,38 @@ impl AgentRegistry { workspace_root: &Path, registrations: Vec, routes: BTreeMap, + ) { + self.install_external_subagent_routes_with_policy( + workspace_root, + registrations, + routes, + true, + ); + } + + /// Atomically publish a complete, validated external route generation. + /// Routes omitted by the new generation are intentionally released so a + /// same-name local Agent becomes visible again. + pub fn replace_external_subagent_routes( + &self, + workspace_root: &Path, + registrations: Vec, + routes: BTreeMap, + ) { + self.install_external_subagent_routes_with_policy( + workspace_root, + registrations, + routes, + false, + ); + } + + fn install_external_subagent_routes_with_policy( + &self, + workspace_root: &Path, + registrations: Vec, + routes: BTreeMap, + preserve_missing_external_routes: bool, ) { let workspace_root = canonical_local_workspace_path(workspace_root); { @@ -359,12 +478,12 @@ impl AgentRegistry { .get(&workspace_root) .cloned() .unwrap_or_default(); - // An active external implementation disappearing must never expose a - // same-name local implementation implicitly. Keep a fail-closed route - // until the external candidate returns or product reconciliation - // records an explicit Local choice. + // Discovery and temporary-unavailable updates preserve missing + // external ownership. A validated plugin generation replacement does + // not, because removing a contributed Agent must restore local routing. for (logical_id, previous_route) in previous { - if !routes.contains_key(&logical_id) + if preserve_missing_external_routes + && !routes.contains_key(&logical_id) && matches!( previous_route, ExternalSubagentRoute::External(_) | ExternalSubagentRoute::Unavailable @@ -387,6 +506,70 @@ impl AgentRegistry { self.external_subagents.prune_unrouted_generations(); } + /// Publish a complete route overlay owned by one extension source. + /// Replacing or removing this owner never mutates the base external-source + /// routes for the workspace. + pub fn replace_external_subagent_route_overlay( + &self, + workspace_root: &Path, + owner: &str, + registrations: Vec, + routes: BTreeMap, + ) { + let workspace_root = canonical_local_workspace_path(workspace_root); + let routes = routes + .into_iter() + .map(|(logical_id, route)| (normalize_external_logical_id(&logical_id), route)) + .collect(); + // Hold the overlay publication lock before making its generations + // visible. A concurrent base-route refresh may prune generations, but + // it cannot observe the new entries without also observing this route + // overlay. + let mut overlays = self.external_subagents.write_route_overlays(); + { + let mut generations = self.external_subagents.write_generations(); + for registration in registrations { + let runtime_key = registration.runtime_key.clone(); + let lease_count = generations + .get(&runtime_key) + .map_or(0, |entry| entry.lease_count); + let agent_entry = AgentEntry { + category: AgentCategory::SubAgent, + source: AgentSource::External, + subagent_source: Some(SubAgentSource::External), + agent: registration.agent.clone(), + visibility_policy: SubagentVisibilityPolicy::public(), + custom_config: None, + }; + generations.insert( + runtime_key, + ExternalSubagentGenerationEntry { + registration, + agent_entry, + lease_count, + }, + ); + } + } + let workspace_overlays = overlays.entry(workspace_root).or_default(); + workspace_overlays.insert(owner.to_string(), routes); + drop(overlays); + self.external_subagents.prune_unrouted_generations(); + } + + pub fn release_external_subagent_route_overlay(&self, workspace_root: &Path, owner: &str) { + let workspace_root = canonical_local_workspace_path(workspace_root); + let mut overlays = self.external_subagents.write_route_overlays(); + if let Some(workspace_overlays) = overlays.get_mut(&workspace_root) { + workspace_overlays.remove(owner); + if workspace_overlays.is_empty() { + overlays.remove(&workspace_root); + } + } + drop(overlays); + self.external_subagents.prune_unrouted_generations(); + } + pub fn resolve_subagent_for_fresh_invocation( &self, logical_id: &str, @@ -399,9 +582,8 @@ impl AgentRegistry { let workspace_key = canonical_local_workspace_path(workspace_root); if let Some(route) = self .external_subagents - .read_routes() - .get(&workspace_key) - .and_then(|routes| routes.get(&logical_key)) + .effective_routes_for_workspace(&workspace_key) + .get(&logical_key) .cloned() { return match route { @@ -429,6 +611,23 @@ impl AgentRegistry { workspace_root: Option<&Path>, external_sources_supported: bool, expected_owner: Option, + ) -> Option { + self.resolve_primary_agent_for_turn_with_route( + logical_id, + workspace_root, + external_sources_supported, + expected_owner, + None, + ) + } + + pub fn resolve_primary_agent_for_turn_with_route( + &self, + logical_id: &str, + workspace_root: Option<&Path>, + external_sources_supported: bool, + expected_owner: Option, + expected_route_key: Option<&str>, ) -> Option { let logical_key = normalize_external_logical_id(logical_id); if external_sources_supported { @@ -436,16 +635,15 @@ impl AgentRegistry { let workspace_key = canonical_local_workspace_path(workspace_root); if let Some(route) = self .external_subagents - .read_routes() - .get(&workspace_key) - .and_then(|routes| routes.get(&logical_key)) + .effective_routes_for_workspace(&workspace_key) + .get(&logical_key) .cloned() { let binding = match route { ExternalSubagentRoute::Local => { match self.find_agent_entry(logical_id, Some(workspace_root)) { Some(entry) if is_local_session_primary_entry(&entry) => { - Some(local_primary_binding(entry.agent.id())) + Some(local_primary_binding(&entry)) } Some(entry) => { warn!( @@ -466,6 +664,9 @@ impl AgentRegistry { }; return binding.filter(|binding| { expected_owner.is_none_or(|owner| binding.route_owner == owner) + && expected_route_key.is_none_or(|route_key| { + binding.route_key.as_deref() == Some(route_key) + }) }); } } @@ -473,9 +674,9 @@ impl AgentRegistry { if expected_owner == Some(SessionAgentRouteOwner::External) { return None; } - match self.find_agent_entry(logical_id, workspace_root) { + let binding = match self.find_agent_entry(logical_id, workspace_root) { Some(entry) if is_local_session_primary_entry(&entry) => { - Some(local_primary_binding(entry.agent.id())) + Some(local_primary_binding(&entry)) } Some(entry) => { warn!( @@ -494,7 +695,11 @@ impl AgentRegistry { ); None } - } + }; + binding.filter(|binding| { + expected_route_key + .is_none_or(|route_key| binding.route_key.as_deref() == Some(route_key)) + }) } /// Resolve only the currently approved external route for an exact @@ -511,9 +716,8 @@ impl AgentRegistry { let logical_key = normalize_external_logical_id(logical_id); let route = self .external_subagents - .read_routes() - .get(&workspace_key) - .and_then(|routes| routes.get(&logical_key)) + .effective_routes_for_workspace(&workspace_key) + .get(&logical_key) .cloned()?; match route { ExternalSubagentRoute::External(runtime_key) => { @@ -535,10 +739,7 @@ impl AgentRegistry { let workspace_root = canonical_local_workspace_path(workspace_root); let routes = self .external_subagents - .read_routes() - .get(&workspace_root) - .cloned() - .unwrap_or_default(); + .effective_routes_for_workspace(&workspace_root); let generations = self.external_subagents.read_generations(); for (logical_id, route) in routes { match route { @@ -572,10 +773,7 @@ impl AgentRegistry { let workspace_root = canonical_local_workspace_path(workspace_root); let routes = self .external_subagents - .read_routes() - .get(&workspace_root) - .cloned() - .unwrap_or_default(); + .effective_routes_for_workspace(&workspace_root); let generations = self.external_subagents.read_generations(); for (logical_id, route) in routes { match route { @@ -642,9 +840,11 @@ fn is_local_session_primary_entry(entry: &AgentEntry) -> bool { && is_builtin_session_primary_agent(entry.agent.id())) } -fn local_primary_binding(runtime_agent_key: &str) -> ExternalPrimaryAgentTurnBinding { +fn local_primary_binding(entry: &AgentEntry) -> ExternalPrimaryAgentTurnBinding { + let route_key = AgentInfo::from_agent_entry(entry).key; ExternalPrimaryAgentTurnBinding { - runtime_agent_key: runtime_agent_key.to_string(), + runtime_agent_key: entry.agent.id().to_string(), + route_key: Some(route_key), model_binding: None, route_owner: SessionAgentRouteOwner::Local, lease: None, @@ -658,11 +858,7 @@ fn external_agent_info( let agent = entry.registration.agent.as_ref(); let default_tools = agent.default_tools(); AgentInfo { - key: format!( - "external::{}::{}", - entry.registration.provider_label.to_ascii_lowercase(), - entry.registration.logical_id - ), + key: entry.registration.route_key.clone(), id: entry.registration.logical_id.clone(), name: agent.name().to_string(), description: agent.description().to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index a6fdb41951..e7eedb5ef1 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -150,6 +150,34 @@ impl AgentRegistry { .map(|entry| entry.agent) } + /// Resolve the effective non-external definition that an external route + /// displaces. Plugin generations use this immutable baseline instead of + /// accidentally inheriting a previous external generation. + pub(crate) fn get_local_agent( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Option> { + if let Some(entry) = self.read_agents().values().find(|entry| { + entry.source != types::AgentSource::External + && entry.agent.id().eq_ignore_ascii_case(agent_type) + }) { + return Some(entry.agent.clone()); + } + workspace_root + .and_then(|root| { + self.read_project_subagents() + .get(root)? + .values() + .find(|entry| { + entry.source != types::AgentSource::External + && entry.agent.id().eq_ignore_ascii_case(agent_type) + }) + .cloned() + }) + .map(|entry| entry.agent) + } + /// Check if an agent exists pub fn check_agent_exists(&self, agent_type: &str) -> bool { self.external_subagents.has_generation(agent_type) diff --git a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs index 896fd2a804..13c9b13655 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs @@ -42,12 +42,18 @@ impl AgentRegistry { agent_type: &str, workspace_root: Option<&Path>, ) -> BitFunResult { - let entry = self - .find_agent_entry(agent_type, workspace_root) - .ok_or_else(|| { - error!("[AgentRegistry] Agent not found: {}", agent_type); - BitFunError::agent(format!("[AgentRegistry] Agent not found: {}", agent_type)) - })?; + let externally_owned = workspace_root + .is_some_and(|workspace| self.is_external_subagent_route(agent_type, Some(workspace))); + let entry = if externally_owned { + workspace_root + .and_then(|workspace| self.find_external_route_entry(agent_type, workspace)) + } else { + self.find_agent_entry(agent_type, workspace_root) + }; + let entry = entry.ok_or_else(|| { + error!("[AgentRegistry] Agent not found: {}", agent_type); + BitFunError::agent(format!("[AgentRegistry] Agent not found: {}", agent_type)) + })?; if let Some(config) = entry.custom_config { let model = config.model.trim(); diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index f0e3040c62..1c1ab0706e 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1363,6 +1363,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( vec![ExternalSubagentRegistration { runtime_key: runtime_v1.to_string(), logical_id: "Explore".to_string(), + route_key: "opencode:test:explore".to_string(), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::Fixed { @@ -1486,6 +1487,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( vec![ExternalSubagentRegistration { runtime_key: runtime_v2.to_string(), logical_id: "Explore".to_string(), + route_key: "opencode:test:explore".to_string(), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::Fixed { @@ -1549,6 +1551,7 @@ async fn external_routes_use_one_canonical_workspace_identity_for_all_operations vec![ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: "canonical-profile".to_string(), + route_key: "opencode:test:canonical-profile".to_string(), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, @@ -1572,6 +1575,17 @@ async fn external_routes_use_one_canonical_workspace_identity_for_all_operations .expect("alias path should resolve the installed external generation"); assert_eq!(binding.runtime_agent_key, runtime_key); drop(binding); + let routed_entry = registry + .find_external_route_entry("canonical-profile", &workspace_alias) + .expect("model lookup should resolve the logical id through the external route"); + assert_eq!(routed_entry.agent.id(), runtime_key); + assert_eq!( + registry + .get_model_id_for_agent("canonical-profile", Some(&workspace_alias)) + .await + .expect("external logical id should resolve a model fallback"), + default_model_id_for_builtin_agent("canonical-profile").to_string() + ); assert!(registry .get_modes_info_for_workspace(Some(&workspace_alias), true) .await @@ -1616,6 +1630,7 @@ async fn external_agent_role_controls_main_and_task_projection() { let registration = |runtime_key: &str, mode| ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, @@ -1701,6 +1716,7 @@ fn persisted_primary_route_owner_rejects_same_name_route_takeover() { vec![ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, @@ -1744,6 +1760,240 @@ fn persisted_primary_route_owner_rejects_same_name_route_takeover() { .is_none()); } +#[test] +fn validated_generation_replacement_restores_same_name_local_agent() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/plugin-agent-removed"); + let logical_id = "agentic"; + let runtime_key = "external::agentic::generation-1"; + registry.install_external_subagent_routes( + &workspace, + vec![ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }], + [( + logical_id.to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + )] + .into_iter() + .collect(), + ); + let old_turn = registry + .resolve_primary_agent_for_turn(logical_id, Some(&workspace), true, None) + .expect("external generation"); + assert_eq!(old_turn.runtime_agent_key, runtime_key); + + registry.replace_external_subagent_routes(&workspace, Vec::new(), BTreeMap::new()); + + let fresh_turn = registry + .resolve_primary_agent_for_turn(logical_id, Some(&workspace), true, None) + .expect("same-name local agent"); + assert_eq!( + fresh_turn.route_owner, + bitfun_core_types::SessionAgentRouteOwner::Local + ); + assert_eq!(fresh_turn.runtime_agent_key, logical_id); + assert_eq!(old_turn.runtime_agent_key, runtime_key); +} + +#[test] +fn route_overlay_overrides_without_replacing_base_external_routes() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/plugin-agent-overlay"); + let registration = |runtime_key: &str, + logical_id: &str, + provider: &str, + ecosystem: &str| + -> ExternalSubagentRegistration { + ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + route_key: format!("{ecosystem}:{provider}:{logical_id}"), + ecosystem_id: EcosystemId::new(ecosystem).unwrap(), + provider_label: provider.to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + } + }; + let route = |logical_id: &str, runtime_key: &str| { + ( + logical_id.to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + ) + }; + + registry.install_external_subagent_routes( + &workspace, + vec![ + registration("external::base-agentic", "agentic", "Base", "claude-code"), + registration("external::base-only", "base-only", "Base", "claude-code"), + ], + [ + route("agentic", "external::base-agentic"), + route("base-only", "external::base-only"), + ] + .into_iter() + .collect(), + ); + registry.replace_external_subagent_route_overlay( + &workspace, + "opencode-plugin-config", + vec![ + registration("external::plugin-agentic", "agentic", "Plugin", "opencode"), + registration("external::plugin-only", "plugin-only", "Plugin", "opencode"), + ], + [ + route("agentic", "external::plugin-agentic"), + route("plugin-only", "external::plugin-only"), + ] + .into_iter() + .collect(), + ); + + let plugin_turn = registry + .resolve_primary_agent_for_turn("agentic", Some(&workspace), true, None) + .expect("overlay route"); + assert_eq!(plugin_turn.runtime_agent_key, "external::plugin-agentic"); + assert_eq!( + registry + .resolve_primary_agent_for_turn("base-only", Some(&workspace), true, None) + .expect("unrelated base route") + .runtime_agent_key, + "external::base-only" + ); + assert_eq!( + registry + .resolve_primary_agent_for_turn("plugin-only", Some(&workspace), true, None) + .expect("plugin-only overlay route") + .runtime_agent_key, + "external::plugin-only" + ); + + registry.install_external_subagent_routes( + &workspace, + vec![ + registration( + "external::base-agentic-v2", + "agentic", + "Base v2", + "claude-code", + ), + registration("external::base-only", "base-only", "Base", "claude-code"), + ], + [ + route("agentic", "external::base-agentic-v2"), + route("base-only", "external::base-only"), + ] + .into_iter() + .collect(), + ); + assert_eq!( + registry + .resolve_primary_agent_for_turn("agentic", Some(&workspace), true, None) + .expect("overlay still wins after base refresh") + .runtime_agent_key, + "external::plugin-agentic" + ); + + registry.release_external_subagent_route_overlay(&workspace, "opencode-plugin-config"); + + assert_eq!( + registry + .resolve_primary_agent_for_turn("agentic", Some(&workspace), true, None) + .expect("latest base route restored") + .runtime_agent_key, + "external::base-agentic-v2" + ); + assert_eq!( + registry + .resolve_primary_agent_for_turn("base-only", Some(&workspace), true, None) + .expect("base route retained") + .runtime_agent_key, + "external::base-only" + ); + assert!(registry + .resolve_primary_agent_for_turn("plugin-only", Some(&workspace), true, None) + .is_none()); + assert!(registry.check_agent_exists("external::plugin-agentic")); + drop(plugin_turn); + assert!(!registry.check_agent_exists("external::plugin-agentic")); +} + +#[test] +fn persisted_route_key_rejects_same_name_external_provider_takeover() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/plugin-agent-takeover"); + let logical_id = "agentic"; + let registration = |runtime_key: &str, route_key: &str| ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + route_key: route_key.to_string(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }; + registry.replace_external_subagent_routes( + &workspace, + vec![registration("external::one", "opencode:plugin-one:agentic")], + [( + logical_id.to_string(), + ExternalSubagentRoute::External("external::one".to_string()), + )] + .into_iter() + .collect(), + ); + let binding = registry + .resolve_primary_agent_for_turn_with_route( + logical_id, + Some(&workspace), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + Some("opencode:plugin-one:agentic"), + ) + .expect("original route"); + drop(binding); + + registry.replace_external_subagent_routes( + &workspace, + vec![registration("external::two", "opencode:plugin-two:agentic")], + [( + logical_id.to_string(), + ExternalSubagentRoute::External("external::two".to_string()), + )] + .into_iter() + .collect(), + ); + + assert!(registry + .resolve_primary_agent_for_turn_with_route( + logical_id, + Some(&workspace), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + Some("opencode:plugin-one:agentic"), + ) + .is_none()); +} + #[test] fn external_primary_route_follows_the_session_execution_worktree() { let registry = AgentRegistry::new(); @@ -1753,6 +2003,7 @@ fn external_primary_route_follows_the_session_execution_worktree() { let registration = |runtime_key: &str| ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 64e0f48ea5..948eae0437 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1259,6 +1259,7 @@ pub struct ConversationCoordinator { thread_goal_runtime: Arc, terminal_port: OnceLock>, remote_exec_port: OnceLock>, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, } impl ConversationCoordinator { @@ -1622,16 +1623,18 @@ impl ConversationCoordinator { workspace_root: Option<&Path>, external_sources_supported: bool, expected_owner: Option, + expected_route_key: Option<&str>, ) -> BitFunResult { let external_sources_supported = cfg!(feature = "external-sources") && external_sources_supported; let registry = get_agent_registry(); registry.load_custom_agents(workspace_root).await; - let local_binding = registry.resolve_primary_agent_for_turn( + let local_binding = registry.resolve_primary_agent_for_turn_with_route( agent_type, workspace_root, false, expected_owner, + expected_route_key, ); if !external_sources_supported { @@ -1644,11 +1647,12 @@ impl ConversationCoordinator { if let Err(error) = crate::external_sources::ensure_external_source_workspace_snapshot(workspace_root).await { - if let Some(external_binding) = registry.resolve_primary_agent_for_turn( + if let Some(external_binding) = registry.resolve_primary_agent_for_turn_with_route( agent_type, workspace_root, true, expected_owner, + expected_route_key, ) { warn!( "External agent source discovery failed; continuing with the existing resolved route: agent_type={}, route_owner={:?}, error_category={}", @@ -1679,11 +1683,12 @@ impl ConversationCoordinator { } registry - .resolve_primary_agent_for_turn( + .resolve_primary_agent_for_turn_with_route( agent_type, workspace_root, true, expected_owner, + expected_route_key, ) .ok_or_else(|| { if expected_owner == Some(SessionAgentRouteOwner::External) @@ -1711,11 +1716,16 @@ impl ConversationCoordinator { let expected_owner = agent_type .eq_ignore_ascii_case(&session.agent_type) .then_some(session.config.agent_route_owner); + let expected_route_key = agent_type + .eq_ignore_ascii_case(&session.agent_type) + .then(|| session.config.agent_route_key.as_deref()) + .flatten(); Self::resolve_primary_agent_for_workspace( agent_type, workspace_root, external_sources_supported, expected_owner, + expected_route_key, ) .await } @@ -2313,9 +2323,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet thread_goal_runtime: Arc::new(ThreadGoalRuntime::new()), terminal_port: OnceLock::new(), remote_exec_port: OnceLock::new(), + hook_registry: crate::native_hooks::new_runtime_hook_registry(), } } + pub(crate) fn hook_registry(&self) -> &bitfun_agent_runtime::native_hooks::RuntimeHookRegistry { + &self.hook_registry + } + fn ensure_runtime_ownership( &self, workspace_path: &Path, @@ -2671,6 +2686,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet )?; config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; let agent_type = Self::normalize_agent_type(&agent_type); + let expected_route_key = config.agent_route_key.clone(); let workspace_binding = Self::build_workspace_binding(&config).await; let external_workspace_root = crate::agentic::workspace::session_execution_workspace_root(&config); @@ -2682,9 +2698,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet external_workspace_root, external_sources_supported, None, + expected_route_key.as_deref(), ) .await?; config.agent_route_owner = primary_agent_binding.route_owner; + config.agent_route_key = primary_agent_binding.route_key.clone(); apply_primary_agent_model_default( &mut config, primary_agent_binding.model_binding.as_ref(), @@ -4158,6 +4176,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; let primary_runtime_agent_key = primary_agent_binding.runtime_agent_key.clone(); let primary_route_owner = primary_agent_binding.route_owner; + let primary_route_key = primary_agent_binding.route_key.clone(); let primary_agent_generation_lease = primary_agent_binding.lease; let binding = get_agent_registry() @@ -4186,12 +4205,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; if session.agent_type != effective_agent_type || session.config.agent_route_owner != primary_route_owner + || session.config.agent_route_key != primary_route_key { self.session_manager .update_session_agent_binding( &session_id, &effective_agent_type, primary_route_owner, + primary_route_key, ) .await?; } @@ -5834,6 +5855,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; let runtime_agent_type = primary_agent_binding.runtime_agent_key.clone(); + let primary_route_key = primary_agent_binding.route_key.clone(); let external_agent_generation_lease = primary_agent_binding.lease; // Resolve Swarm lineage before creating or mutating any turn state. A @@ -5876,12 +5898,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet if session.agent_type != effective_agent_type || session.config.agent_route_owner != primary_agent_binding.route_owner + || session.config.agent_route_key != primary_route_key { self.session_manager .update_session_agent_binding( &session_id, &effective_agent_type, primary_agent_binding.route_owner, + primary_route_key, ) .await?; } @@ -12522,6 +12546,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } pub async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> BitFunResult<()> { + self.update_session_mode_with_route(session_id, mode_id, None) + .await + } + + async fn update_session_mode_with_route( + &self, + session_id: &str, + mode_id: &str, + expected_route_key: Option<&str>, + ) -> BitFunResult<()> { self.ensure_session_runtime_ownership(session_id, None)?; let mode_id = mode_id.trim(); if mode_id.is_empty() { @@ -12545,11 +12579,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_root, external_sources_supported, None, + expected_route_key, ) .await?; self.session_manager - .update_session_agent_binding(session_id, mode_id, binding.route_owner) + .update_session_agent_binding( + session_id, + mode_id, + binding.route_owner, + binding.route_key, + ) .await } @@ -12711,6 +12751,7 @@ async fn create_agent_session_from_runtime_request( request.session_name, request.agent_type, SessionConfig { + agent_route_key: request.agent_route_key, workspace_path: Some(workspace_path.clone()), project_workspace_path: request.project_workspace_path, execution_target: request.execution_target, @@ -13569,9 +13610,13 @@ impl bitfun_runtime_ports::AgentSessionModePort for ConversationCoordinator { &self, request: bitfun_runtime_ports::AgentSessionModeUpdateRequest, ) -> bitfun_runtime_ports::PortResult<()> { - self.update_session_mode(&request.session_id, &request.mode_id) - .await - .map_err(runtime_port_error_preserving_message) + self.update_session_mode_with_route( + &request.session_id, + &request.mode_id, + request.agent_route_key.as_deref(), + ) + .await + .map_err(runtime_port_error_preserving_message) } } @@ -15279,6 +15324,7 @@ mod tests { &session_id, &external_agent_id, SessionAgentRouteOwner::External, + Some("test:external".to_string()), ) .await .expect("persist external route owner"); @@ -15323,6 +15369,7 @@ mod tests { &session_id, &external_agent_id, SessionAgentRouteOwner::External, + Some("test:external".to_string()), ) .await .expect("persist external route owner"); @@ -15342,7 +15389,12 @@ mod tests { assert_eq!(binding.route_owner, SessionAgentRouteOwner::Local); session_manager - .update_session_agent_binding(&session_id, "AGENTIC", SessionAgentRouteOwner::External) + .update_session_agent_binding( + &session_id, + "AGENTIC", + SessionAgentRouteOwner::External, + Some("test:external".to_string()), + ) .await .expect("persist case-variant external route owner"); let case_variant_session = session_manager @@ -15919,6 +15971,7 @@ mod tests { AgentSessionModeUpdateRequest { session_id: "missing-session".to_string(), mode_id: "agentic".to_string(), + agent_route_key: None, }, ) .await @@ -15961,6 +16014,7 @@ mod tests { AgentSessionModeUpdateRequest { session_id: session.session_id, mode_id: " ".to_string(), + agent_route_key: None, }, ) .await @@ -16006,6 +16060,7 @@ mod tests { AgentSessionModeUpdateRequest { session_id: session.session_id, mode_id: "__missing_runtime_mode__".to_string(), + agent_route_key: None, }, ) .await @@ -16056,6 +16111,7 @@ mod tests { .update_session_mode(AgentSessionModeUpdateRequest { session_id: session.session_id.clone(), mode_id: " Cowork ".to_string(), + agent_route_key: None, }) .await .expect("runtime mode port should update the Core owner"); @@ -18410,6 +18466,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -18449,6 +18506,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Original".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace.clone()), project_workspace_path: None, execution_target: None, @@ -18555,6 +18613,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Over capacity".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(std::env::temp_dir().to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -18586,6 +18645,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Fixed worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -18620,6 +18680,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Duplicate worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -19034,6 +19095,7 @@ mod tests { let request = |name: &str| AgentSessionCreateRequest { session_name: name.to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace.clone()), project_workspace_path: None, execution_target: None, @@ -19167,6 +19229,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Invalid worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(std::env::temp_dir().to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, diff --git a/src/crates/assembly/core/src/agentic/deep_review/mod.rs b/src/crates/assembly/core/src/agentic/deep_review/mod.rs index cd975fb36d..207c70321e 100644 --- a/src/crates/assembly/core/src/agentic/deep_review/mod.rs +++ b/src/crates/assembly/core/src/agentic/deep_review/mod.rs @@ -14,4 +14,3 @@ pub mod capabilities; pub mod report; pub mod scope; pub mod task_adapter; -pub mod tool_measurement; diff --git a/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs b/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs deleted file mode 100644 index 3df9c6bfe5..0000000000 --- a/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Deep Review shared-context measurement hook for successful tool calls. -//! -//! The hook is intentionally narrow: only successful reviewer `Read` and -//! `GetFileDiff` calls are measured, and BitFun runtime URIs are ignored. It -//! records normalized metadata for diagnostics, not file contents. - -use crate::agentic::deep_review_policy::record_deep_review_shared_context_tool_use; -use crate::agentic::tools::framework::ToolUseContext; -use bitfun_agent_runtime::post_call_hooks::{ - resolve_deep_review_shared_context_tool_use, DeepReviewSharedContextToolUseFacts, -}; -use serde_json::Value; - -pub(crate) fn maybe_record_shared_context_tool_use( - tool_name: &str, - input: &Value, - context: &ToolUseContext, -) { - let Some(record) = - resolve_deep_review_shared_context_tool_use(DeepReviewSharedContextToolUseFacts { - tool_name, - input, - custom_data: &context.custom_data, - workspace_root: context.workspace_root(), - is_remote: context.is_remote(), - agent_type: context.agent_type.as_deref(), - }) - else { - return; - }; - - record_deep_review_shared_context_tool_use( - &record.parent_turn_id, - &record.subagent_type, - &record.tool_name, - &record.measured_path, - ); -} diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 7c37277b28..502ec2f172 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -114,6 +114,21 @@ fn runtime_context_needs_for_manifest(manifest: &ResolvedToolManifest) -> Runtim ) } +fn apply_agent_temperature_override( + agent: &dyn crate::agentic::agents::Agent, + client: Arc, +) -> Arc { + let Some(temperature) = agent.model_temperature_override() else { + return client; + }; + if client.config.temperature == Some(temperature) { + return client; + } + let mut derived = client.as_ref().clone(); + derived.config.temperature = Some(temperature); + Arc::new(derived) +} + fn resolve_round_permission_mode( active_turn_mode: Option, fixed_context_mode: Option, @@ -2613,6 +2628,7 @@ impl ExecutionEngine { ))); } }; + let ai_client = apply_agent_temperature_override(current_agent.as_ref(), ai_client); Self::validate_frozen_model_contract(context).await?; Self::validate_frozen_reasoning_contract(context, ai_client.as_ref())?; let model_request_context = Self::model_request_context( @@ -3537,6 +3553,7 @@ impl ExecutionEngine { ))); } }; + let ai_client = apply_agent_temperature_override(current_agent.as_ref(), ai_client); Self::validate_frozen_model_contract(&context).await?; Self::validate_frozen_reasoning_contract(&context, ai_client.as_ref())?; let model_request_context = Self::model_request_context( diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 0d235b2efa..855fd7a848 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -4010,6 +4010,7 @@ impl SessionManager { session_id: &str, agent_type: &str, route_owner: SessionAgentRouteOwner, + route_key: Option, ) -> BitFunResult<()> { let _mutation_guard = self.acquire_session_mutation(session_id).await?; let original_session = self @@ -4019,6 +4020,7 @@ impl SessionManager { .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; if original_session.agent_type == agent_type && original_session.config.agent_route_owner == route_owner + && original_session.config.agent_route_key == route_key { return Ok(()); } @@ -4027,6 +4029,7 @@ impl SessionManager { let now = SystemTime::now(); updated_session.agent_type = agent_type.to_string(); updated_session.config.agent_route_owner = route_owner; + updated_session.config.agent_route_key = route_key.clone(); updated_session.updated_at = now; updated_session.last_activity_at = now; @@ -4058,11 +4061,12 @@ impl SessionManager { }; active_session.agent_type = updated_session.agent_type; active_session.config.agent_route_owner = route_owner; + active_session.config.agent_route_key = route_key.clone(); active_session.updated_at = now; active_session.last_activity_at = now; debug!( - "Session agent binding updated: session_id={}, agent_type={}, route_owner={:?}", - session_id, agent_type, route_owner + "Session agent binding updated: session_id={}, agent_type={}, route_owner={:?}, route_key={:?}", + session_id, agent_type, route_owner, route_key ); Ok(()) @@ -5909,15 +5913,27 @@ impl SessionManager { let available_modes = agent_registry .get_modes_info_for_workspace(external_workspace_root, external_sources_supported) .await; - let persisted_binding = agent_registry.resolve_primary_agent_for_turn( + let persisted_binding = agent_registry.resolve_primary_agent_for_turn_with_route( &session.agent_type, external_workspace_root, external_sources_supported, Some(session.config.agent_route_owner), + session.config.agent_route_key.as_deref(), ); if let Some(binding) = persisted_binding { - if session.config.agent_route_owner != binding.route_owner { + // A missing local route key is a valid legacy binding. Local + // resolution is already constrained by the persisted owner, so + // avoid rewriting the runtime state solely to backfill it. + // External bindings still persist their exact provider route + // key to prevent a same-name provider from taking over. + let external_route_key_changed = session.config.agent_route_owner + == SessionAgentRouteOwner::External + && session.config.agent_route_key != binding.route_key; + if session.config.agent_route_owner != binding.route_owner + || external_route_key_changed + { session.config.agent_route_owner = binding.route_owner; + session.config.agent_route_key = binding.route_key; should_persist_restored_session = true; } } else if session.config.agent_route_owner == SessionAgentRouteOwner::External { @@ -5943,6 +5959,7 @@ impl SessionManager { ); session.agent_type = fallback_mode; session.config.agent_route_owner = SessionAgentRouteOwner::Local; + session.config.agent_route_key = None; should_persist_restored_session = true; } } @@ -13148,6 +13165,7 @@ mod tests { &session.session_id, "agentic", SessionAgentRouteOwner::External, + Some("test:external:agentic".to_string()), ) .await .expect("same-id local-to-external rebind should persist"); @@ -13164,6 +13182,7 @@ mod tests { &session.session_id, "agentic", SessionAgentRouteOwner::Local, + Some("local:agentic".to_string()), ) .await .expect("same-id external-to-local rebind should persist"); @@ -13181,6 +13200,7 @@ mod tests { &session.session_id, "Cowork", SessionAgentRouteOwner::External, + Some("test:external:plan".to_string()), ) .await .expect("external route update should persist without a turn"); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 67cbda055a..d477ece0cc 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -406,6 +406,7 @@ Arguments: .create_session(AgentSessionCreateRequest { session_name, agent_type, + agent_route_key: None, workspace_path: Some(workspace.display_workspace.clone()), project_workspace_path: Some(workspace.project_workspace.clone()), execution_target: workspace.execution_target.clone(), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 58bf95dd93..45464f4312 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -660,6 +660,7 @@ Allowed agent types when creating a session: .create_session(AgentSessionCreateRequest { session_name, agent_type: agent_type.clone(), + agent_route_key: None, workspace_path: Some(workspace_target.workspace_path.clone()), project_workspace_path: Some( workspace_target.project_workspace_path.clone(), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 676345e847..9935163661 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -1243,10 +1243,41 @@ impl SkillRegistry { async fn apply_mode_filters_for_workspace( &self, - candidates: Vec, + mut candidates: Vec, workspace_root: Option<&Path>, agent_type: Option<&str>, ) -> Vec { + #[cfg(feature = "opencode-plugin-host")] + { + let plugin_roots = + crate::plugin_config_projection::skill_roots_for_agent(workspace_root, agent_type) + .into_iter() + .map(|root| { + LocalConfiguredSkillRootContribution { + path: root.path, + scope: + bitfun_product_domains::external_sources::ExternalSourceScope::WorkspaceLocal, + precedence: root.precedence, + } + }) + .collect::>(); + if !plugin_roots.is_empty() { + let existing_paths = candidates + .iter() + .map(canonical_candidate_path) + .collect::>(); + let mut plugin_candidates = + Self::scan_configured_opencode_candidates(plugin_roots).await; + plugin_candidates.retain(|candidate| { + !existing_paths.contains(&canonical_candidate_path(candidate)) + }); + candidates = Self::merge_configured_opencode_candidates( + candidates, + plugin_candidates, + workspace_root.is_some(), + ); + } + } let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; let candidates = Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index f90326a623..3c122e03f5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -20,6 +20,19 @@ fn external_subagent_model_override_requested( model_id.is_some() || inherit_parent_model } +pub(super) fn resolved_subagent_is_available( + available_agent_types: &[String], + logical_id: &str, + runtime_agent_key: &str, +) -> bool { + available_agent_types + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(logical_id)) + || available_agent_types + .iter() + .any(|candidate| candidate == runtime_agent_key) +} + fn build_deep_review_subagent_context( role: DeepReviewSubagentRole, subagent_type: Option<&str>, @@ -359,9 +372,18 @@ impl TaskTool { subagent_type )) })?; - if !all_agent_types.contains(&subagent_type) - && !all_agent_types.contains(&binding.runtime_agent_key) - { + // External Agent routes are resolved using their canonical + // case-insensitive logical id, but the model may emit a + // different casing (for example `Explore` for the + // plugin-registered `explore`). Validate against the + // resolved logical id as well as the generation key so a + // successful route lookup is not rejected by this second + // check. + if !resolved_subagent_is_available( + &all_agent_types, + &binding.logical_id, + &binding.runtime_agent_key, + ) { return Err(BitFunError::tool(format!( "subagent_type {} is not valid, must be one of: {}", subagent_type, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index 743faf9d4c..5e22003392 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -1,5 +1,6 @@ use super::{ - AgentInterruptTool, AgentSendInputTool, AgentSpawnTool, LaunchReviewAgentTool, TaskTool, + execution::resolved_subagent_is_available, AgentInterruptTool, AgentSendInputTool, + AgentSpawnTool, LaunchReviewAgentTool, TaskTool, }; use crate::agentic::agents::CustomSubagentConfig; use crate::agentic::agents::{ @@ -24,6 +25,17 @@ struct PromptOrderTestAgent { id: String, } +#[test] +fn external_subagent_validation_accepts_model_emitted_casing() { + let available = vec!["FileFinder".to_string(), "explore".to_string()]; + + assert!(resolved_subagent_is_available( + &available, + "Explore", + "external_subagent_runtime:opencode-plugin:explore" + )); +} + #[async_trait] impl Agent for PromptOrderTestAgent { fn as_any(&self) -> &dyn std::any::Any { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs index 20da8ef920..870a15d502 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs @@ -515,6 +515,7 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio .session_name .unwrap_or_else(|| "New Worktree Session".to_string()), agent_type: input.agent_type.unwrap_or_else(|| "agentic".to_string()), + agent_route_key: None, workspace_path: Some(created.execution_target.root_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), execution_target: Some(created.execution_target.clone()), diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index 8f7422d58d..8879c0a805 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -24,6 +24,9 @@ pub mod page_deploy_host; #[cfg(feature = "tools-miniapp")] pub mod page_publish_host; pub mod pipeline; +#[cfg(feature = "plugin-runtime")] +#[cfg(feature = "opencode-plugin-host")] +pub mod plugin_host_tool; pub(crate) mod post_call_hooks; #[doc(hidden)] pub mod product_runtime; 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 024a138ea0..7245c3ffb8 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 @@ -13,6 +13,8 @@ use crate::agentic::tools::registry::ToolRegistry; use crate::agentic::tools::tool_context_runtime; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::tool_result_storage; +#[cfg(feature = "opencode-plugin-host")] +use crate::agentic::WorkspaceBinding; use crate::native_hooks::{self, NativeHookSessionFacts}; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; @@ -39,7 +41,6 @@ use bitfun_runtime_ports::{ use futures::future::join_all; use log::{debug, error, info, warn}; use std::collections::{HashMap, HashSet}; -use std::path::Path; use std::sync::Arc; use std::time::{Instant, SystemTime}; use tokio::sync::{Mutex as TokioMutex, RwLock as TokioRwLock}; @@ -53,19 +54,15 @@ use tool_runtime::pipeline::{ fn resolve_contextual_tool( tool: Arc, - workspace_root: Option<&Path>, - remote: bool, + context: &ToolUseContext, ) -> Option> { #[cfg(feature = "external-sources")] { - return crate::external_tools::resolve_external_tool_for_workspace( - tool, - crate::external_tools::external_tool_route_root(workspace_root, remote), - ); + return crate::external_tools::resolve_external_tool_for_context(tool, context); } #[cfg(not(feature = "external-sources"))] { - let _ = (workspace_root, remote); + let _ = context; Some(tool) } } @@ -77,6 +74,49 @@ fn persisted_effective_tool_name( (wire_tool_name != effective_tool_name).then(|| effective_tool_name.to_string()) } +#[cfg(feature = "opencode-plugin-host")] +struct PluginAfterPresentation { + title: String, + output: String, + metadata: serde_json::Value, +} + +#[cfg(feature = "opencode-plugin-host")] +fn plugin_after_presentation( + tool_name: &str, + tool_result: &ModelToolResult, +) -> PluginAfterPresentation { + let object = tool_result.result.as_object(); + let title = object + .and_then(|value| value.get("title")) + .and_then(serde_json::Value::as_str) + .unwrap_or(tool_name) + .to_string(); + let output = object + .and_then(|value| value.get("output")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| tool_result.result_for_assistant.clone()) + .unwrap_or_else(|| tool_result.result.to_string()); + let metadata = object + .and_then(|value| value.get("metadata")) + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| serde_json::json!({"isError": tool_result.is_error})); + PluginAfterPresentation { + title, + output, + metadata, + } +} + +#[cfg(feature = "opencode-plugin-host")] +fn local_plugin_workspace_scope(workspace: &WorkspaceBinding) -> Option { + (!workspace.is_remote()) + .then(|| crate::plugin_host::canonical_plugin_workspace_scope(workspace.root_path())) + .flatten() +} + /// Convert framework::ToolResult to core::ToolResult /// /// Ensure always has result_for_assistant, avoid tool message content being empty @@ -402,6 +442,9 @@ const ROUND_INJECTION_RUNNING_TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled because a pending round injection requested running-tool preemption for this turn."; fn should_retry_tool_error(error: &BitFunError) -> bool { + if matches!(error, BitFunError::OutcomeUnknown(_)) { + return false; + } matches!( error, BitFunError::Timeout(_) @@ -811,25 +854,13 @@ impl ToolPipeline { return None; } + let tool_context = self.build_tool_use_context(task, CancellationToken::new()); let tool = { let registry = self.tool_registry.read().await; registry .get_tool(task.effective_tool_name()) - .and_then(|tool| { - resolve_contextual_tool( - tool, - task.context - .workspace - .as_ref() - .map(|workspace| workspace.root_path()), - task.context - .workspace - .as_ref() - .is_some_and(|workspace| workspace.is_remote()), - ) - }) + .and_then(|tool| resolve_contextual_tool(tool, &tool_context)) }?; - let tool_context = self.build_tool_use_context(task, CancellationToken::new()); let validation = tool .validate_input_rewrite_invariants( &task.original_effective_arguments, @@ -868,7 +899,7 @@ impl ToolPipeline { /// but cannot relax non-relaxable validation of the original input. async fn apply_pre_tool_use_hooks(&self, task_ids: &[String]) { for task_id in task_ids { - let Some(task) = self.state_manager.get_task(task_id) else { + let Some(mut task) = self.state_manager.get_task(task_id) else { continue; }; if task.invocation_resolution_error.is_some() @@ -878,6 +909,52 @@ impl ToolPipeline { continue; } let tool_name = task.invocation.effective_tool_name.clone(); + #[cfg(feature = "opencode-plugin-host")] + if let Some(workspace_scope) = task + .context + .workspace + .as_ref() + .and_then(local_plugin_workspace_scope) + { + match native_hooks::dispatch_plugin_tool_before( + &workspace_scope, + &tool_name, + Some(&task.context.session_id), + Some(&task.tool_call.tool_id), + Some(&task.context.agent_type), + task.invocation.effective_arguments.clone(), + ) + .await + { + Ok(Some(updated_input)) => { + if self.apply_hook_input_rewrite(&task, updated_input).await { + info!( + "OpenCode plugin hook rewrite was rejected by original-input constraints: tool_name={}, tool_id={}", + tool_name, task_id + ); + continue; + } + let Some(updated_task) = self.state_manager.get_task(task_id) else { + continue; + }; + task = updated_task; + } + Ok(None) => {} + Err(reason) => { + error!( + "OpenCode plugin before hook rejected tool execution: tool_name={}, tool_id={}, error={}", + tool_name, task_id, reason + ); + self.permission_plans.lock().await.insert( + task_id.clone(), + PermissionExecutionPlan::Rejected { + reason: format!("OpenCode plugin before hook failed: {reason}"), + }, + ); + continue; + } + } + } let decision = native_hooks::dispatch_pre_tool_use( native_hook_session_facts(&task.context, &task.options), &tool_name, @@ -937,21 +1014,10 @@ impl ToolPipeline { if task.invocation_resolution_error.is_some() { return (false, false); } + let tool_context = self.build_tool_use_context(&task, CancellationToken::new()); let tool = registry .get_tool(task.effective_tool_name()) - .and_then(|tool| { - resolve_contextual_tool( - tool, - task.context - .workspace - .as_ref() - .map(|workspace| workspace.root_path()), - task.context - .workspace - .as_ref() - .is_some_and(|workspace| workspace.is_remote()), - ) - }); + .and_then(|tool| resolve_contextual_tool(tool, &tool_context)); let tool_is_concurrency_safe = tool .as_ref() .map(|tool| tool.is_concurrency_safe(Some(task.effective_arguments()))) @@ -970,9 +1036,66 @@ impl ToolPipeline { .collect() } - /// Run PostToolUse hooks for a completed tool call and fold blocking + /// Give the OpenCode after-hook the complete model-visible output before + /// large-result storage replaces it with a file reference. + async fn apply_plugin_post_tool_use_hook( + &self, + task: &ToolTask, + tool_name: &str, + tool_id: &str, + tool_result: &mut ModelToolResult, + ) { + #[cfg(feature = "opencode-plugin-host")] + if let Some(workspace_scope) = task + .context + .workspace + .as_ref() + .and_then(local_plugin_workspace_scope) + { + let presentation = plugin_after_presentation(tool_name, tool_result); + match native_hooks::dispatch_plugin_tool_after( + &workspace_scope, + tool_name, + Some(&task.context.session_id), + Some(tool_id), + Some(&task.context.agent_type), + task.invocation.effective_arguments.clone(), + presentation.title, + presentation.output, + presentation.metadata, + ) + .await + { + Ok(Some(transformed)) => { + // Keep the canonical raw tool result immutable for audit + // and persistence. OpenCode's presentation output is the + // model-visible result consumed by the rest of the turn. + tool_result.result_for_assistant = Some(transformed.into_model_output()); + } + Ok(None) => {} + Err(reason) => { + // The tool has already executed. Surface the hook failure + // on this result without returning to the retry loop. + error!( + "OpenCode plugin after hook failed after tool execution: tool_name={}, tool_id={}, error={}", + tool_name, tool_id, reason + ); + let original = tool_result.result_for_assistant.take().unwrap_or_default(); + let failure = format!("OpenCode plugin after hook failed: {reason}"); + tool_result.result_for_assistant = Some(if original.is_empty() { + failure + } else { + format!("{original}\n\n{failure}") + }); + tool_result.is_error = true; + } + } + } + } + + /// Run native PostToolUse hooks after storage compaction and fold blocking /// feedback and additional context into the model-visible result text. - async fn apply_post_tool_use_hooks( + async fn apply_native_post_tool_use_hooks( &self, task: &ToolTask, tool_name: &str, @@ -2025,6 +2148,12 @@ impl ToolPipeline { match result { Ok(tool_result) => { let duration_ms = elapsed_ms_u64(start_time); + let mut tool_result = tool_result; + tool_result.duration_ms = Some(duration_ms); + + self.apply_plugin_post_tool_use_hook(&task, &tool_name, &tool_id, &mut tool_result) + .await; + let mut tool_result = tool_result_storage::maybe_persist_large_tool_result_for_tool( tool_result, @@ -2032,7 +2161,6 @@ impl ToolPipeline { &tool_context, ) .await; - tool_result.duration_ms = Some(duration_ms); if !matches!(repair_kind, ToolArgumentRepairKind::None) || recovered_from_truncation { @@ -2054,8 +2182,13 @@ impl ToolPipeline { }); } - self.apply_post_tool_use_hooks(&task, &tool_name, &tool_id, &mut tool_result) - .await; + self.apply_native_post_tool_use_hooks( + &task, + &tool_name, + &tool_id, + &mut tool_result, + ) + .await; self.state_manager .update_state( @@ -2276,11 +2409,7 @@ impl ToolPipeline { let execution_future = tool.call(task.effective_arguments(), &tool_context); - let timeout_owner = resolve_contextual_tool( - Arc::clone(&tool), - tool_context.workspace_root(), - tool_context.is_remote(), - ); + let timeout_owner = resolve_contextual_tool(Arc::clone(&tool), &tool_context); let pipeline_timeout_secs = if timeout_owner .as_ref() .is_some_and(|selected| selected.manages_own_execution_timeout()) @@ -2545,6 +2674,66 @@ mod tests { } } + #[test] + fn plugin_after_presentation_prefers_the_plugin_result_contract() { + let result = ModelToolResult { + tool_id: "call-a".to_string(), + tool_name: "report".to_string(), + effective_tool_name: None, + result: json!({ + "title": "Generated report", + "output": "report ready", + "metadata": {"path": "report.md"} + }), + result_for_assistant: Some("report ready".to_string()), + is_error: false, + duration_ms: None, + image_attachments: None, + }; + + let presentation = plugin_after_presentation("report", &result); + assert_eq!(presentation.title, "Generated report"); + assert_eq!(presentation.output, "report ready"); + assert_eq!(presentation.metadata["path"], "report.md"); + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn plugin_after_presentation_normalizes_non_object_metadata() { + let result = ModelToolResult { + tool_id: "call-a".to_string(), + tool_name: "report".to_string(), + effective_tool_name: None, + result: json!({ + "title": "Generated report", + "output": "report ready", + "metadata": ["legacy"] + }), + result_for_assistant: Some("report ready".to_string()), + is_error: false, + duration_ms: None, + image_attachments: None, + }; + + let presentation = plugin_after_presentation("report", &result); + assert_eq!(presentation.metadata, json!({"isError": false})); + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn remote_workspace_never_resolves_a_local_plugin_hook_scope() { + let workspace = tempfile::tempdir().expect("workspace"); + let local = WorkspaceBinding::new(None, workspace.path().to_path_buf()); + let mut remote = local.clone(); + remote.backend = crate::agentic::workspace::WorkspaceBackend::Remote { + connection_id: "remote-a".to_string(), + connection_name: "Remote A".to_string(), + }; + + assert!(local_plugin_workspace_scope(&local).is_some()); + assert!(local_plugin_workspace_scope(&remote).is_none()); + } + #[test] fn recovered_write_without_separator_is_rejected_as_potentially_truncated_path() { assert!(recovered_write_has_potentially_truncated_marked_path( diff --git a/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs new file mode 100644 index 0000000000..2278164d0a --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs @@ -0,0 +1,1041 @@ +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_events::ToolExecutionProgressInfo; +use bitfun_runtime_ports::{ + HookFunctionCancelRequest, HookFunctionGeneration, HookFunctionReverseAsk, + HookFunctionReverseMetadata, HookFunctionReverseReply, HookFunctionReverseSink, + HookFunctionRuntime, HookFunctionToolContext, HookFunctionToolRequest, HookFunctionToolResult, + PortError, PortErrorKind, PortResult, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, OnceLock, RwLock}; +use std::time::Duration; + +#[derive(Clone)] +struct PluginHostToolRoute { + runtime: Arc, + host_generation: u64, + instance_id: String, + generation_key: String, + revision: String, + registration_id: String, + description: String, + parameters: Value, + allowed_runtime_agent_keys: BTreeSet, +} + +#[derive(Clone)] +struct PluginToolExecutionRoute { + session_id: String, + dialog_turn_id: String, + agent: String, + tool_name: String, + tool_call_id: Option, + generation_key: String, + revision: String, + cancelled: tokio::sync::watch::Receiver, +} + +fn executions() -> &'static dashmap::DashMap<(String, String), PluginToolExecutionRoute> { + static EXECUTIONS: OnceLock> = + OnceLock::new(); + EXECUTIONS.get_or_init(dashmap::DashMap::new) +} + +struct PluginToolExecutionGuard { + key: (String, String), + cancel: tokio::sync::watch::Sender, +} + +fn dispatched_tool_cancel_error(stopped: bool) -> BitFunError { + let detail = if stopped { + "OpenCode plugin tool stopped after cancellation, but its side effects may already have committed" + } else { + "OpenCode plugin tool cancellation was not confirmed" + }; + BitFunError::OutcomeUnknown(detail.to_string()) +} + +impl Drop for PluginToolExecutionGuard { + fn drop(&mut self) { + let _ = self.cancel.send(true); + executions().remove(&self.key); + } +} + +struct PluginHostToolMux { + id: String, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, + routes: RwLock>, +} + +impl PluginHostToolMux { + fn new( + id: String, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, + ) -> Self { + Self { + id, + hook_registry, + routes: RwLock::new(BTreeMap::new()), + } + } + fn set_route(&self, workspace_scope: String, route: PluginHostToolRoute) { + let generation_key = route.generation_key.clone(); + self.routes + .write() + .expect("plugin tool route lock poisoned") + .insert((workspace_scope, generation_key), route); + } + fn remove_route(&self, workspace_scope: &str, generation_key: &str) -> bool { + self.routes + .write() + .expect("plugin tool route lock poisoned") + .remove(&(workspace_scope.to_string(), generation_key.to_string())); + self.routes + .read() + .expect("plugin tool route lock poisoned") + .is_empty() + } + fn routes_for_scope(&self, workspace_scope: &str) -> Vec { + if self.hook_registry.source_activation_for_workspace( + bitfun_agent_runtime::native_hooks::RuntimeHookSource::Plugin, + Some(workspace_scope), + ) != bitfun_agent_runtime::native_hooks::RuntimeHookActivation::Ready + { + return Vec::new(); + } + self.routes + .read() + .expect("plugin tool route lock poisoned") + .iter() + .filter(|((scope, _), _)| scope == workspace_scope) + .map(|(_, route)| route) + .cloned() + .collect() + } + fn route_for(&self, context: Option<&ToolUseContext>) -> Option { + let context = context?; + if context.is_remote() { + return None; + } + let runtime_agent_key = context.agent_type.as_deref()?; + let scope = context + .workspace_root() + .and_then(crate::plugin_host::canonical_plugin_workspace_scope)?; + self.routes_for_scope(&scope).into_iter().find(|route| { + route.allowed_runtime_agent_keys.is_empty() + || route.allowed_runtime_agent_keys.contains(runtime_agent_key) + }) + } +} + +#[async_trait] +impl Tool for PluginHostToolMux { + fn name(&self) -> &str { + &self.id + } + async fn description(&self) -> BitFunResult { + Ok(self + .routes + .read() + .expect("plugin tool route lock poisoned") + .values() + .next() + .map(|r| r.description.clone()) + .unwrap_or_default()) + } + async fn description_with_context( + &self, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + Ok(self + .route_for(context) + .map(|r| r.description) + .unwrap_or_default()) + } + fn short_description(&self) -> String { + self.id.clone() + } + fn input_schema(&self) -> Value { + self.routes + .read() + .expect("plugin tool route lock poisoned") + .values() + .next() + .map(|r| r.parameters.clone()) + .unwrap_or_else(|| serde_json::json!({"type":"object"})) + } + async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { + self.route_for(context) + .map(|r| r.parameters) + .unwrap_or_else(|| serde_json::json!({"type":"object"})) + } + fn dynamic_provider_id(&self) -> Option<&str> { + Some("opencode-plugin") + } + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(vec![ + crate::agentic::tools::framework::PermissionIntent::new( + "custom_tool", + vec![self.id.clone()], + ), + ]) + } + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + self.route_for(context).is_some() + } + fn manages_own_execution_timeout(&self) -> bool { + // Side-effecting plugin calls must reach the typed timeout/cancel path; + // the generic pipeline timeout would drop the RPC future without a + // confirmed terminal outcome. + true + } + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let route = self.route_for(Some(context)).ok_or_else(|| { + BitFunError::service("OpenCode plugin tool is not registered for this workspace") + })?; + // OpenCode execution IDs are Host-local operation identities. A model + // tool-call ID is only a presentation correlation and can repeat across + // sessions, so never use it as the reverse-RPC routing key. + let execution_id = uuid::Uuid::new_v4().to_string(); + let generation = HookFunctionGeneration { + instance_id: route.instance_id.clone(), + generation_key: route.generation_key.clone(), + revision: route.revision.clone(), + }; + let execution_key = (route.instance_id.clone(), execution_id.clone()); + let (cancel, cancelled) = tokio::sync::watch::channel(false); + executions().insert( + execution_key.clone(), + PluginToolExecutionRoute { + session_id: context.session_id.clone().unwrap_or_default(), + dialog_turn_id: context.dialog_turn_id.clone().unwrap_or_default(), + agent: context.agent_type.clone().unwrap_or_default(), + tool_name: self.id.clone(), + tool_call_id: context.tool_call_id.clone(), + generation_key: route.generation_key.clone(), + revision: route.revision.clone(), + cancelled, + }, + ); + let _execution_guard = PluginToolExecutionGuard { + key: execution_key.clone(), + cancel, + }; + let call = route.runtime.execute_tool( + HookFunctionToolRequest { + generation: generation.clone(), + execution_id: execution_id.clone(), + registration_id: route.registration_id.clone(), + args: input.clone(), + context: HookFunctionToolContext { + session_id: context.session_id.clone().unwrap_or_default(), + message_id: context.dialog_turn_id.clone().unwrap_or_default(), + agent: context.agent_type.clone().unwrap_or_default(), + call_id: context.tool_call_id.clone(), + }, + }, + Duration::from_secs(120), + ); + let result = if let Some(token) = context.cancellation_token() { + tokio::select! { + value = call => value, + _ = token.cancelled() => { + let cancelled = match route.runtime.cancel( + HookFunctionCancelRequest { + generation: generation.clone(), + execution_id: execution_id.clone(), + reason: Some("cancelled".to_string()), + }, + Duration::from_secs(5), + ).await { + Ok(cancelled) => cancelled, + Err(error) => { + crate::plugin_host::fault_configured_plugin_host_generation( + route.host_generation, + "plugin tool cancellation failed", + ).await; + return Err(BitFunError::OutcomeUnknown(error.to_string())); + } + }; + return if cancelled.stopped { + Err(dispatched_tool_cancel_error(true)) + } else { + crate::plugin_host::fault_configured_plugin_host_generation( + route.host_generation, + "plugin tool cancellation was not confirmed", + ) + .await; + Err(dispatched_tool_cancel_error(false)) + }; + } + } + } else { + call.await + }; + let result = result.map_err(|error| match error.kind { + // A timed-out/cancelled side-effecting plugin call may still have + // completed in the Host. Preserve this distinction so the tool + // pipeline never retries it as a transient service failure. + PortErrorKind::OutcomeUnknown => BitFunError::OutcomeUnknown(format!( + "OpenCode plugin tool '{}' outcome is unknown: {}", + self.id, error.message + )), + PortErrorKind::Cancelled => BitFunError::Cancelled(error.message), + PortErrorKind::Timeout => BitFunError::Timeout(error.message), + PortErrorKind::PermissionDenied => BitFunError::Validation(error.message), + _ => BitFunError::service(format!( + "OpenCode plugin tool '{}' failed: {error}", + self.id + )), + }); + if matches!(result, Err(BitFunError::OutcomeUnknown(_))) { + crate::plugin_host::fault_configured_plugin_host_generation( + route.host_generation, + "plugin tool invocation outcome is unknown", + ) + .await; + } + Ok(vec![map_plugin_tool_result(result?)]) + } +} + +fn map_plugin_tool_result(result: HookFunctionToolResult) -> ToolResult { + let mut output = result.output; + if !result.attachments.is_empty() { + let attachments = serde_json::to_value(result.attachments).unwrap_or(Value::Null); + match &mut output { + Value::Object(object) => { + object.insert("attachments".to_string(), attachments); + } + other => { + output = serde_json::json!({ + "output": std::mem::take(other), + "attachments": attachments, + }); + } + } + } + let (data, assistant) = match output { + Value::String(value) => (Value::String(value.clone()), Some(value)), + Value::Object(object) => { + let output = object.get("output").cloned().unwrap_or(Value::Null); + let assistant = object + .get("output") + .and_then(Value::as_str) + .map(str::to_string); + ( + Value::Object(object), + assistant.or_else(|| Some(output.to_string())), + ) + } + other => (other.clone(), Some(other.to_string())), + }; + ToolResult::ok(data, assistant) +} + +struct PluginToolReverseSink; + +pub(crate) fn reverse_sink() -> Arc { + Arc::new(PluginToolReverseSink) +} + +#[async_trait] +impl HookFunctionReverseSink for PluginToolReverseSink { + async fn metadata(&self, params: HookFunctionReverseMetadata) -> PortResult<()> { + handle_tool_metadata(params).await + } + + async fn ask(&self, params: HookFunctionReverseAsk) -> PortResult { + handle_tool_ask(params).await + } +} + +async fn handle_tool_metadata(params: HookFunctionReverseMetadata) -> PortResult<()> { + let route = executions() + .get(&( + params.generation.instance_id.clone(), + params.execution_id.clone(), + )) + .map(|entry| entry.clone()) + .ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "plugin tool execution is no longer active", + ) + })?; + validate_reverse_generation(¶ms.generation, &route)?; + let progress_message = if params.title.is_empty() { + Value::Object(params.metadata).to_string() + } else { + params.title + }; + crate::infrastructure::events::emit_global_event( + crate::infrastructure::events::BackendEvent::ToolExecutionProgress( + ToolExecutionProgressInfo { + tool_use_id: route.tool_call_id.clone().unwrap_or(params.execution_id), + tool_name: route.tool_name, + progress_message, + percentage: None, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }, + ), + ) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("failed to publish plugin tool metadata: {error}"), + ) + })?; + Ok(()) +} + +async fn handle_tool_ask(params: HookFunctionReverseAsk) -> PortResult { + let instance_id = ¶ms.generation.instance_id; + let execution_id = ¶ms.execution_id; + let route = executions() + .get(&(instance_id.to_string(), execution_id.to_string())) + .map(|entry| entry.clone()) + .ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "plugin tool execution is no longer active", + ) + })?; + let mut cancelled = route.cancelled.clone(); + validate_reverse_generation(¶ms.generation, &route)?; + let instance = crate::plugin_host::plugin_host_instance_by_id(instance_id) + .await + .ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "plugin instance is unavailable", + ) + })?; + if instance.generation_key != route.generation_key || instance.revision != route.revision { + return Err(PortError::new( + PortErrorKind::NotAvailable, + "plugin tool generation is no longer active", + )); + } + let mut patterns = params.patterns; + let permission_action = if params.permission == route.tool_name { + if patterns.is_empty() { + patterns.push(route.tool_name.clone()); + } + "custom_tool" + } else { + ¶ms.permission + }; + let policy = crate::agentic::agents::get_agent_registry() + .get_agent_tool_policy(&route.agent, Some(&instance.directory)) + .await; + let evaluator = bitfun_runtime_ports::PermissionEvaluator::case_sensitive(); + if patterns.iter().any(|resource| { + evaluator.evaluate_constraint_resource( + permission_action, + resource, + &policy.permission_constraints, + ) == bitfun_runtime_ports::PermissionEffect::Deny + }) { + return Err(PortError::new( + PortErrorKind::PermissionDenied, + "plugin tool permission denied by the active agent policy", + )); + } + // The pipeline already admitted the exact plugin Tool through the same + // custom_tool intent before entering the Host. Do not ask twice when the + // plugin repeats that declaration through context.ask(). + if permission_action == "custom_tool" + && patterns.iter().all(|resource| resource == &route.tool_name) + { + return Ok(HookFunctionReverseReply::Once); + } + let manager = crate::product_runtime::core_permission_request_manager() + .map_err(|error| PortError::new(PortErrorKind::Backend, error))?; + let request_id = uuid::Uuid::new_v4().to_string(); + let mut pending = manager + .register_batch_for_turn( + vec![bitfun_runtime_ports::PermissionRequest { + request_id: request_id.clone(), + round_id: route.dialog_turn_id.clone(), + order: 0, + tool_call_id: route + .tool_call_id + .clone() + .or_else(|| Some(execution_id.to_string())), + project_path: Some(instance.canonical_directory.clone()), + project_id: instance.project_id, + session_id: route.session_id, + agent_id: route.agent, + action: permission_action.to_string(), + resources: patterns, + save_resources: params.always, + source: bitfun_runtime_ports::PermissionRequestSource { + kind: bitfun_runtime_ports::PermissionRequestSourceKind::Extension, + identity: instance_id.to_string(), + }, + delegation: None, + display_metadata: params.metadata, + }], + route.dialog_turn_id, + ) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))?; + let pending = pending + .pop() + .expect("single permission batch must return one receiver"); + let outcome = tokio::select! { + outcome = pending.wait() => outcome, + changed = cancelled.changed() => { + let reason = if changed.is_ok() && *cancelled.borrow() { + "Plugin tool execution ended while permission was pending" + } else { + "Plugin tool execution route closed while permission was pending" + }; + manager + .cancel_request(&request_id, reason) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))?; + return Err(PortError::new(PortErrorKind::Cancelled, reason)); + } + }; + match outcome { + bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( + bitfun_runtime_ports::PermissionReply::Once, + ) => Ok(HookFunctionReverseReply::Once), + bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( + bitfun_runtime_ports::PermissionReply::Always, + ) => Ok(HookFunctionReverseReply::Always), + bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( + bitfun_runtime_ports::PermissionReply::Reject { feedback }, + ) => Ok(HookFunctionReverseReply::Reject { feedback }), + bitfun_agent_runtime::permission::PermissionWaitOutcome::Cancelled { reason } => { + Ok(HookFunctionReverseReply::Reject { + feedback: Some(reason), + }) + } + } +} + +fn validate_reverse_generation( + generation: &HookFunctionGeneration, + route: &PluginToolExecutionRoute, +) -> PortResult<()> { + if generation.generation_key == route.generation_key && generation.revision == route.revision { + Ok(()) + } else { + Err(PortError::new( + PortErrorKind::NotAvailable, + "plugin tool reverse RPC generation lease does not match the active execution", + )) + } +} + +fn muxes() -> &'static RwLock>> { + static MUXES: OnceLock>>> = OnceLock::new(); + MUXES.get_or_init(|| RwLock::new(BTreeMap::new())) +} + +pub(crate) async fn register_workspace_tool( + workspace_scope: &str, + workspace_root: &std::path::Path, + runtime: Arc, + host_generation: u64, + instance_id: &str, + generation_key: &str, + revision: &str, + registration_id: &str, + id: &str, + description: &str, + parameters: Value, + config_fingerprint: &str, + allowed_runtime_agent_keys: BTreeSet, +) { + let mux = { + let mut muxes = muxes().write().expect("plugin tool mux lock poisoned"); + if let Some(mux) = muxes.get(id) { + mux.clone() + } else { + let mux = Arc::new(PluginHostToolMux::new( + id.to_string(), + crate::native_hooks::runtime_hook_registry(), + )); + muxes.insert(id.to_string(), mux.clone()); + mux + } + }; + let mut hasher = Sha256::new(); + hasher.update(id.as_bytes()); + hasher.update([0]); + hasher.update(description.as_bytes()); + hasher.update([0]); + hasher.update(serde_json::to_vec(¶meters).unwrap_or_default()); + hasher.update([0]); + hasher.update(config_fingerprint.as_bytes()); + let content_version = format!("sha256:{}", hex::encode(hasher.finalize())); + let native_agent_visible = allowed_runtime_agent_keys.is_empty(); + mux.set_route( + workspace_scope.to_string(), + PluginHostToolRoute { + runtime, + host_generation, + instance_id: instance_id.to_string(), + generation_key: generation_key.to_string(), + revision: revision.to_string(), + registration_id: registration_id.to_string(), + description: description.to_string(), + parameters, + allowed_runtime_agent_keys, + }, + ); + crate::external_tools::register_live_external_tool_candidate( + workspace_root, + mux, + "opencode-plugin", + content_version, + native_agent_visible, + ) + .await; +} + +pub(crate) async fn unregister_workspace_tools( + workspace_scope: &str, + workspace_root: &std::path::Path, + names: &[String], + generation_key: &str, +) { + for name in names { + let mux = muxes() + .read() + .expect("plugin tool mux lock poisoned") + .get(name) + .cloned(); + let Some(mux) = mux else { + continue; + }; + if mux.remove_route(workspace_scope, generation_key) { + muxes() + .write() + .expect("plugin tool mux lock poisoned") + .remove(name); + } + if mux.routes_for_scope(workspace_scope).is_empty() { + crate::external_tools::unregister_live_external_tool_candidate( + workspace_root, + name, + "opencode-plugin", + ) + .await; + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + dispatched_tool_cancel_error, executions, handle_tool_ask, handle_tool_metadata, + map_plugin_tool_result, PluginHostToolMux, PluginHostToolRoute, PluginToolExecutionGuard, + PluginToolExecutionRoute, + }; + use crate::agentic::tools::framework::{Tool, ToolUseContext}; + use bitfun_opencode_plugin_host::JsonRpcPeer; + use bitfun_runtime_ports::{ + HookFunctionGeneration, HookFunctionReverseAsk, HookFunctionReverseMetadata, + HookFunctionToolAttachment, HookFunctionToolResult, PortErrorKind, + }; + use serde_json::json; + use std::collections::BTreeSet; + use std::path::PathBuf; + use tokio::net::{TcpListener, TcpStream}; + + async fn client() -> bitfun_opencode_plugin_host::PluginHostClient { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let host = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (backend, _) = listener.accept().await.unwrap(); + let _host = host.await.unwrap(); + JsonRpcPeer::start_with_capabilities( + backend, + 1, + 1024 * 1024, + bitfun_opencode_plugin_host::PluginHostCapabilities::all_supported(), + ) + .client() + } + + fn route( + client: bitfun_opencode_plugin_host::PluginHostClient, + instance_id: &str, + ) -> PluginHostToolRoute { + let instance_id = instance_id.to_string(); + PluginHostToolRoute { + runtime: bitfun_opencode_plugin_host::hook_function_runtime(client), + host_generation: 1, + instance_id: instance_id.clone(), + generation_key: "generation-test".to_string(), + revision: "revision-test".to_string(), + registration_id: format!("registration-{instance_id}"), + description: format!("description-{instance_id}"), + parameters: json!({"type": "object", "title": instance_id}), + allowed_runtime_agent_keys: BTreeSet::from(["plugin-agent".to_string()]), + } + } + + fn context(workspace_root: &str, runtime_agent_key: &str) -> ToolUseContext { + crate::agentic::tools::tool_context_runtime::build_tool_description_context( + runtime_agent_key, + Some(&crate::agentic::WorkspaceBinding::new( + None, + PathBuf::from(workspace_root), + )), + None, + None, + None, + None, + None, + &Default::default(), + &Default::default(), + ) + } + + #[test] + fn plugin_tools_own_their_side_effecting_timeout_path() { + let mux = PluginHostToolMux::new( + "timeout-tool".to_string(), + bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(), + ); + + assert!(mux.manages_own_execution_timeout()); + } + + #[test] + fn dispatched_tool_abort_is_not_reported_as_a_definite_cancellation() { + let error = dispatched_tool_cancel_error(true); + + assert!(matches!(error, crate::BitFunError::OutcomeUnknown(_))); + } + + #[test] + fn dropping_an_execution_route_cancels_reverse_permission_waits() { + let key = ("drop-instance".to_string(), "drop-execution".to_string()); + let (cancel, mut cancelled) = tokio::sync::watch::channel(false); + executions().insert( + key.clone(), + PluginToolExecutionRoute { + session_id: "session-a".to_string(), + dialog_turn_id: "turn-a".to_string(), + agent: "agentic".to_string(), + tool_name: "plugin-tool".to_string(), + tool_call_id: Some("tool-call-a".to_string()), + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + cancelled: cancelled.clone(), + }, + ); + + drop(PluginToolExecutionGuard { + key: key.clone(), + cancel, + }); + + assert!(*cancelled.borrow_and_update()); + assert!(!executions().contains_key(&key)); + } + + #[test] + fn plugin_tool_result_preserves_presentation_fields_and_attachments() { + let result = map_plugin_tool_result(HookFunctionToolResult { + output: json!({ + "title": "Generated report", + "output": "report ready", + "metadata": {"path": "report.md"} + }), + attachments: vec![HookFunctionToolAttachment { + mime: "text/markdown".to_string(), + url: "file:///workspace/report.md".to_string(), + filename: Some("report.md".to_string()), + }], + }); + + let crate::agentic::tools::framework::ToolResult::Result { + data, + result_for_assistant, + .. + } = result + else { + panic!("plugin result should be terminal"); + }; + assert_eq!(data["title"], "Generated report"); + assert_eq!(data["metadata"]["path"], "report.md"); + assert_eq!(data["attachments"][0]["filename"], "report.md"); + assert_eq!(result_for_assistant.as_deref(), Some("report ready")); + } + + #[tokio::test] + async fn same_named_tool_routes_are_isolated_by_workspace() { + let client = client().await; + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + for scope in ["C:/workspace-a", "D:/workspace-b"] { + registry.set_source_activation_for_workspace( + bitfun_agent_runtime::native_hooks::RuntimeHookSource::Plugin, + Some(scope), + bitfun_agent_runtime::native_hooks::RuntimeHookActivation::Ready, + ); + } + let mux = PluginHostToolMux::new("shared-tool".to_string(), registry); + mux.set_route( + "C:/workspace-a".to_string(), + route(client.clone(), "instance-a"), + ); + mux.set_route("D:/workspace-b".to_string(), route(client, "instance-b")); + + assert_eq!( + mux.routes_for_scope("C:/workspace-a") + .pop() + .unwrap() + .instance_id, + "instance-a" + ); + assert_eq!( + mux.routes_for_scope("D:/workspace-b") + .pop() + .unwrap() + .instance_id, + "instance-b" + ); + assert!(!mux.remove_route("C:/workspace-a", "generation-test")); + assert!(mux.routes_for_scope("C:/workspace-a").is_empty()); + assert_eq!( + mux.routes_for_scope("D:/workspace-b") + .pop() + .unwrap() + .instance_id, + "instance-b" + ); + assert!(mux.remove_route("D:/workspace-b", "generation-test")); + } + + #[tokio::test] + async fn tool_route_honors_the_shared_workspace_activation_gate() { + use bitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; + + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + let mux = PluginHostToolMux::new("gated-tool".to_string(), registry.clone()); + mux.set_route( + "C:/workspace-gated".to_string(), + route(client().await, "instance-gated"), + ); + registry.set_source_activation_for_workspace( + RuntimeHookSource::Plugin, + Some("C:/workspace-gated"), + RuntimeHookActivation::Unavailable, + ); + + assert!(mux.routes_for_scope("C:/workspace-gated").is_empty()); + + registry.set_source_activation_for_workspace( + RuntimeHookSource::Plugin, + Some("C:/workspace-gated"), + RuntimeHookActivation::Ready, + ); + assert!(!mux.routes_for_scope("C:/workspace-gated").is_empty()); + registry.clear_source_workspace(RuntimeHookSource::Plugin, "C:/workspace-gated"); + } + + #[tokio::test] + async fn tool_route_requires_the_exact_generation_agent_key() { + use bitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; + + let workspace = std::env::current_dir().expect("absolute workspace"); + let scope = crate::plugin_host::canonical_plugin_workspace_scope(&workspace) + .expect("canonical workspace scope"); + let generation_a_agent = "external_subagent_runtime:opencode-plugin:generation-a-agent"; + let generation_b_agent = "external_subagent_runtime:opencode-plugin:generation-b-agent"; + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + registry.set_source_activation_for_workspace( + RuntimeHookSource::Plugin, + Some(&scope), + RuntimeHookActivation::Ready, + ); + let mux = PluginHostToolMux::new("generation-tool".to_string(), registry.clone()); + let client = client().await; + let mut route_a = route(client.clone(), "instance-a"); + route_a.generation_key = "generation-a".to_string(); + route_a.allowed_runtime_agent_keys = BTreeSet::from([generation_a_agent.to_string()]); + let mut route_b = route(client, "instance-b"); + route_b.generation_key = "generation-b".to_string(); + route_b.allowed_runtime_agent_keys = BTreeSet::from([generation_b_agent.to_string()]); + mux.set_route(scope.clone(), route_a); + mux.set_route(scope.clone(), route_b); + + let workspace_text = workspace.to_string_lossy(); + assert_eq!( + mux.route_for(Some(&context(&workspace_text, generation_a_agent))) + .expect("generation A route") + .instance_id, + "instance-a" + ); + assert_eq!( + mux.route_for(Some(&context(&workspace_text, generation_b_agent))) + .expect("generation B route") + .instance_id, + "instance-b" + ); + assert!(mux + .route_for(Some(&context( + &workspace_text, + "external_subagent_runtime:other-provider:agent" + ))) + .is_none()); + assert!(mux + .route_for(Some(&context(&workspace_text, "Agentic"))) + .is_none()); + + registry.clear_source_workspace(RuntimeHookSource::Plugin, &scope); + } + + #[tokio::test] + async fn tool_only_plugin_route_is_available_to_native_agents() { + use bitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; + + let workspace = std::env::current_dir().expect("absolute workspace"); + let scope = crate::plugin_host::canonical_plugin_workspace_scope(&workspace) + .expect("canonical workspace scope"); + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + registry.set_source_activation_for_workspace( + RuntimeHookSource::Plugin, + Some(&scope), + RuntimeHookActivation::Ready, + ); + let mux = PluginHostToolMux::new("tool-only".to_string(), registry.clone()); + let mut route = route(client().await, "tool-only-instance"); + route.allowed_runtime_agent_keys.clear(); + mux.set_route(scope.clone(), route); + + let workspace_text = workspace.to_string_lossy(); + let context = context(&workspace_text, "Agentic"); + assert!(mux.route_for(Some(&context)).is_some()); + + registry.clear_source_workspace(RuntimeHookSource::Plugin, &scope); + } + + #[tokio::test] + async fn remote_workspace_cannot_match_a_local_plugin_tool_route() { + use bitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; + + let workspace = std::env::current_dir().expect("absolute workspace"); + let scope = crate::plugin_host::canonical_plugin_workspace_scope(&workspace) + .expect("canonical workspace scope"); + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + registry.set_source_activation_for_workspace( + RuntimeHookSource::Plugin, + Some(&scope), + RuntimeHookActivation::Ready, + ); + let mux = PluginHostToolMux::new("remote-collision".to_string(), registry.clone()); + let mut route = route(client().await, "local-instance"); + route.allowed_runtime_agent_keys.clear(); + mux.set_route(scope.clone(), route); + + let workspace_text = workspace.to_string_lossy(); + let mut remote = context(&workspace_text, "Agentic"); + remote.workspace.as_mut().expect("workspace").backend = + crate::agentic::workspace::WorkspaceBackend::Remote { + connection_id: "remote-a".to_string(), + connection_name: "Remote A".to_string(), + }; + + assert!(mux.route_for(Some(&remote)).is_none()); + registry.clear_source_workspace(RuntimeHookSource::Plugin, &scope); + } + + #[tokio::test] + async fn ask_for_missing_execution_route_is_rejected() { + let error = handle_tool_ask(HookFunctionReverseAsk { + generation: HookFunctionGeneration { + instance_id: "missing-instance".to_string(), + generation_key: "missing-generation".to_string(), + revision: "missing-revision".to_string(), + }, + execution_id: "missing-execution".to_string(), + permission: "custom_tool".to_string(), + patterns: Vec::new(), + always: Vec::new(), + metadata: Default::default(), + }) + .await + .unwrap_err(); + + assert_eq!(error.kind, PortErrorKind::NotAvailable); + } + + #[tokio::test] + async fn metadata_for_missing_execution_route_is_rejected() { + let error = handle_tool_metadata(HookFunctionReverseMetadata { + generation: HookFunctionGeneration { + instance_id: "missing-instance".to_string(), + generation_key: "missing-generation".to_string(), + revision: "missing-revision".to_string(), + }, + execution_id: "missing-execution".to_string(), + title: "progress".to_string(), + metadata: Default::default(), + }) + .await + .unwrap_err(); + + assert_eq!(error.kind, PortErrorKind::NotAvailable); + } + + #[tokio::test] + async fn metadata_for_active_execution_is_published_as_progress() { + let instance_id = format!("metadata-instance-{}", std::process::id()); + let execution_id = format!("metadata-execution-{}", std::process::id()); + let key = (instance_id.clone(), execution_id.clone()); + executions().insert( + key.clone(), + PluginToolExecutionRoute { + session_id: "session-a".to_string(), + dialog_turn_id: "turn-a".to_string(), + agent: "agentic".to_string(), + tool_name: "plugin-tool".to_string(), + tool_call_id: Some("tool-call-a".to_string()), + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + cancelled: tokio::sync::watch::channel(false).1, + }, + ); + + let result = handle_tool_metadata(HookFunctionReverseMetadata { + generation: HookFunctionGeneration { + instance_id, + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + }, + execution_id, + title: "Reading README.md".to_string(), + metadata: serde_json::from_value(json!({"path": "README.md"})).unwrap(), + }) + .await; + executions().remove(&key); + + result.unwrap(); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index db411435d2..c3fde4538e 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -4,31 +4,21 @@ //! tool execution. Domain-specific hooks must keep their own gating inside the //! owning domain module. -use crate::agentic::deep_review::tool_measurement; use crate::agentic::tools::tool_context_runtime::ToolUseContext; -use bitfun_agent_runtime::post_call_hooks::{ - run_successful_tool_post_call_hooks, SuccessfulToolPostCallHookExecutor, -}; use serde_json::Value; -struct CorePostCallHookExecutor; - -impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExecutor { - fn record_deep_review_shared_context_tool_use( - &mut self, - tool_name: &str, - input: &Value, - context: &ToolUseContext, - ) { - tool_measurement::maybe_record_shared_context_tool_use(tool_name, input, context); - } -} - -pub(crate) fn record_successful_tool_call( +pub(crate) async fn record_successful_tool_call( tool_name: &str, input: &Value, context: &ToolUseContext, ) { - let mut executor = CorePostCallHookExecutor; - run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor); + crate::native_hooks::dispatch_successful_tool_post_call( + context.workspace_root(), + context.is_remote(), + tool_name, + input, + &context.custom_data, + context.agent_type.as_deref(), + ) + .await; } 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 e9279bc605..eee9121b6d 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 @@ -8,10 +8,10 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolDefinition; use bitfun_agent_tools::{ resolve_contextual_tool_manifest, resolve_contextual_visible_tools, ContextualToolManifest, - ContextualVisibleTools, DynamicToolInfo, - GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, - GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, - ToolManifestDefinition, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, + ContextualVisibleTools, DynamicToolInfo, GetToolSpecCatalogProvider, + GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, GetToolSpecRuntime, + ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolManifestDefinition, + CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; use serde::Serialize; use serde_json::Value; @@ -298,12 +298,14 @@ pub(crate) async fn resolve_product_visible_tools( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ContextualVisibleTools { - let (allowed_tools, exposure_overrides) = ProductToolCatalogProvider::resolve_manifest_inputs( - allowed_tools, - exposure_overrides, - context, - ); + let (mut allowed_tools, exposure_overrides) = + ProductToolCatalogProvider::resolve_manifest_inputs( + allowed_tools, + exposure_overrides, + context, + ); let tool_snapshot = contextual_tool_snapshot(context).await; + append_selected_plugin_tool_names(&mut allowed_tools, &tool_snapshot, context).await; resolve_contextual_visible_tools( &tool_snapshot, &allowed_tools, @@ -319,12 +321,14 @@ pub(crate) async fn resolve_product_tool_manifest( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ContextualToolManifest { - let (allowed_tools, exposure_overrides) = ProductToolCatalogProvider::resolve_manifest_inputs( - allowed_tools, - exposure_overrides, - context, - ); + let (mut allowed_tools, exposure_overrides) = + ProductToolCatalogProvider::resolve_manifest_inputs( + allowed_tools, + exposure_overrides, + context, + ); let tool_snapshot = contextual_tool_snapshot(context).await; + append_selected_plugin_tool_names(&mut allowed_tools, &tool_snapshot, context).await; resolve_contextual_tool_manifest( &tool_snapshot, &allowed_tools, @@ -335,6 +339,29 @@ pub(crate) async fn resolve_product_tool_manifest( .await } +async fn append_selected_plugin_tool_names( + allowed_tools: &mut Vec, + tool_snapshot: &[ToolRef], + context: &ToolUseContext, +) { + #[cfg(feature = "opencode-plugin-host")] + if !context.is_remote() { + for tool in tool_snapshot { + let name = tool.name(); + if tool.dynamic_provider_id() != Some("opencode-plugin") + || !context.runtime_tool_restrictions.is_tool_allowed(name) + || allowed_tools.iter().any(|allowed| allowed == name) + || !tool.is_available_in_context(Some(context)).await + { + continue; + } + allowed_tools.push(name.to_string()); + } + } + #[cfg(not(feature = "opencode-plugin-host"))] + let _ = (allowed_tools, tool_snapshot, context); +} + async fn contextual_tool_snapshot(context: &ToolUseContext) -> Vec { #[cfg(feature = "external-sources")] { @@ -346,14 +373,10 @@ async fn contextual_tool_snapshot(context: &ToolUseContext) -> Vec { } let registry = get_global_tool_registry(); let tools = registry.read().await.get_all_tools(); - let route_root = crate::external_tools::external_tool_route_root( - context.workspace_root(), - context.is_remote(), - ); return tools .into_iter() .filter_map(|tool| { - crate::external_tools::resolve_external_tool_for_workspace(tool, route_root) + crate::external_tools::resolve_external_tool_for_context(tool, context) }) .collect(); } @@ -409,16 +432,16 @@ pub(crate) async fn resolve_product_get_tool_spec_results( #[cfg(test)] mod tests { use super::{ - resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, - resolve_product_resolved_tool_manifest, resolve_product_resolved_visible_tools, - resolve_product_tool_manifest, ProductToolCatalogProvider, - DEFERRED_TOOL_LOADING_CONTEXT_KEY, + append_selected_plugin_tool_names, resolve_product_get_tool_spec_results, + resolve_product_readonly_enabled_tools, resolve_product_resolved_tool_manifest, + resolve_product_resolved_visible_tools, resolve_product_tool_manifest, + ProductToolCatalogProvider, DEFERRED_TOOL_LOADING_CONTEXT_KEY, }; use crate::agentic::agents::AgentToolPolicyOverrides; use crate::agentic::tools::framework::{ DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolExposure, ToolResult, }; - use crate::agentic::tools::registry::create_tool_registry; + use crate::agentic::tools::registry::{create_tool_registry, ToolRef}; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::ToolRuntimeRestrictions; #[cfg(feature = "external-sources")] @@ -435,6 +458,42 @@ mod tests { struct DeferredMcpCatalogTool; + struct SelectedCatalogTool { + name: &'static str, + provider: Option<&'static str>, + } + + #[async_trait::async_trait] + impl Tool for SelectedCatalogTool { + fn name(&self) -> &str { + self.name + } + + async fn description(&self) -> crate::util::errors::BitFunResult { + Ok(self.name.to_string()) + } + + fn short_description(&self) -> String { + self.name.to_string() + } + + fn input_schema(&self) -> Value { + json!({"type": "object"}) + } + + fn dynamic_provider_id(&self) -> Option<&str> { + self.provider + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> crate::util::errors::BitFunResult> { + Ok(Vec::new()) + } + } + #[async_trait::async_trait] impl Tool for DeferredMcpCatalogTool { fn name(&self) -> &str { @@ -511,6 +570,32 @@ mod tests { tool_context(None) } + #[cfg(feature = "opencode-plugin-host")] + #[tokio::test] + async fn selected_plugin_tools_extend_manifest_inputs_without_unlocking_colliding_builtins() { + let selected: Vec = vec![ + Arc::new(SelectedCatalogTool { + name: "shadowed_builtin", + provider: None, + }), + Arc::new(SelectedCatalogTool { + name: "plugin_only", + provider: Some("opencode-plugin"), + }), + ]; + let mut allowed_tools = vec!["Read".to_string()]; + + append_selected_plugin_tool_names( + &mut allowed_tools, + &selected, + &tool_context(Some("Agentic")), + ) + .await; + + assert!(allowed_tools.iter().any(|name| name == "plugin_only")); + assert!(!allowed_tools.iter().any(|name| name == "shadowed_builtin")); + } + #[cfg(feature = "external-sources")] #[test] fn remote_workspace_route_root_isolated_from_same_local_path() { diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index a119a6e096..cc1015c31f 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -231,7 +231,7 @@ pub(crate) async fn call_with_tool_runtime_hooks( }; if result.is_ok() { - post_call_hooks::record_successful_tool_call(tool_name, input, context); + post_call_hooks::record_successful_tool_call(tool_name, input, context).await; } result diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 791ee9da04..df9bf8196f 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -144,7 +144,6 @@ const MAX_APPROVED_PROMPT_COMMAND_SHELL_PLANS: usize = 512; /// ecosystems, but the cap keeps a corrupted or hostile file from growing /// without limit. const MAX_ACKNOWLEDGED_ECOSYSTEMS: usize = 256; - #[derive(Debug, Clone, PartialEq, Eq)] struct ResolvedPromptCommandShell { display_name: String, @@ -2907,7 +2906,24 @@ impl WorkspaceExternalSourceService { } fn snapshot(&self) -> ExternalSourceCatalogSnapshot { - lock_snapshot(&self.snapshot).clone() + #[cfg(feature = "opencode-plugin-host")] + let mut snapshot = lock_snapshot(&self.snapshot).clone(); + #[cfg(not(feature = "opencode-plugin-host"))] + let snapshot = lock_snapshot(&self.snapshot).clone(); + #[cfg(feature = "opencode-plugin-host")] + for message in crate::plugin_host::configured_plugin_activation_failures( + self.workspace_root.as_deref(), + ) { + if !snapshot.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "plugin.activation_failed" && diagnostic.message == message + }) { + snapshot.diagnostics.push( + ExternalSourceDiagnostic::warning("plugin.activation_failed", message, None) + .with_asset_kind(ExternalSourceAssetKind::Hook), + ); + } + } + snapshot } fn source_location(&self, stable_key: &str) -> Result { @@ -3521,10 +3537,21 @@ impl WorkspaceExternalSourceService { ) })?; if matches!(candidate.kind, ExternalToolConflictCandidateKind::External) { - let source_key = candidate.source.as_ref().ok_or_else(|| { - missing_candidate_error("External tool conflict source is missing") - })?; - ensure_source_capability_active(&snapshot, source_key, EXTERNAL_CAPABILITY_TOOL)?; + match candidate.source.as_ref() { + Some(source_key) => { + ensure_source_capability_active( + &snapshot, + source_key, + EXTERNAL_CAPABILITY_TOOL, + )?; + } + None if candidate.provider_id == "opencode-plugin" => {} + None => { + return Err(missing_candidate_error( + "External tool conflict source is missing", + )); + } + } } validate_conflict_preference(conflict_key, candidate_id)?; let preferences = diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 71953f3501..26edb77432 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -1328,6 +1328,7 @@ fn install_active_candidate( candidate.definition.prompt.expose().to_string(), tools, candidate.definition.permission_constraints.clone(), + None, candidate.readonly, candidate.definition.behavior_version.as_str().to_string(), )); @@ -1342,6 +1343,11 @@ fn install_active_candidate( state.registrations.push(ExternalSubagentRegistration { runtime_key: runtime_key.clone(), logical_id: candidate.definition.logical_id.clone(), + route_key: format!( + "{}:{}", + ecosystem_id.as_str(), + candidate.definition.candidate_id.as_str() + ), ecosystem_id, provider_label: candidate.provider_label.clone(), model_binding, diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index 97a60cedc8..d7a4b7112e 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -334,6 +334,11 @@ enum WorkspaceRoute { tool: Arc, conflict: Option, }, + Live { + tool: Arc, + native_agent_visible: bool, + conflict: Option, + }, Unavailable { conflict: Option, }, @@ -344,6 +349,7 @@ impl WorkspaceRoute { match self { Self::Original { conflict } | Self::External { conflict, .. } + | Self::Live { conflict, .. } | Self::Unavailable { conflict } => conflict.as_ref(), } } @@ -431,6 +437,9 @@ fn retain_fail_closed_routes_during_reconcile( WorkspaceRoute::External { conflict: Some(_), .. + } | WorkspaceRoute::Live { + conflict: Some(_), + .. } | WorkspaceRoute::Unavailable { conflict: Some(_) } ) }); @@ -438,10 +447,13 @@ fn retain_fail_closed_routes_during_reconcile( if discovered_names.contains(name) { continue; } - if let WorkspaceRoute::External { conflict, .. } = route { - *route = WorkspaceRoute::Unavailable { - conflict: conflict.clone(), - }; + match route { + WorkspaceRoute::External { conflict, .. } | WorkspaceRoute::Live { conflict, .. } => { + *route = WorkspaceRoute::Unavailable { + conflict: conflict.clone(), + }; + } + _ => {} } } } @@ -502,7 +514,35 @@ impl ExternalToolMux { // a local route solely because the remote path text matches. return self.original(); } - self.selected_for_workspace(context.and_then(ToolUseContext::workspace_root)) + let workspace_key = workspace_route_key(context.and_then(ToolUseContext::workspace_root)); + match self + .routes + .read() + .expect("external tool route lock poisoned") + .get(&workspace_key) + .cloned() + { + #[cfg(feature = "opencode-plugin-host")] + Some(WorkspaceRoute::Live { + tool, + native_agent_visible, + .. + }) if tool.dynamic_provider_id() == Some("opencode-plugin") => { + let uses_plugin_agent = context + .and_then(|context| context.agent_type.as_deref()) + .is_some_and(crate::plugin_config_projection::is_plugin_agent_runtime_key); + let original = self.original(); + if uses_plugin_agent || native_agent_visible || original.is_none() { + Some(tool) + } else { + original + } + } + Some(WorkspaceRoute::External { tool, .. }) => Some(tool), + Some(WorkspaceRoute::Live { tool, .. }) => Some(tool), + Some(WorkspaceRoute::Unavailable { .. }) => None, + Some(WorkspaceRoute::Original { .. }) | None => self.original(), + } } fn selected_for_workspace(&self, workspace_root: Option<&Path>) -> Option> { @@ -515,6 +555,7 @@ impl ExternalToolMux { .cloned() { Some(WorkspaceRoute::External { tool, .. }) => Some(tool), + Some(WorkspaceRoute::Live { tool, .. }) => Some(tool), Some(WorkspaceRoute::Unavailable { .. }) => None, Some(WorkspaceRoute::Original { .. }) | None => self.original(), } @@ -593,7 +634,10 @@ impl Tool for ExternalToolMux { } async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { - self.selected(context).is_some() + match self.selected(context) { + Some(tool) => tool.is_available_in_context(context).await, + None => false, + } } fn is_readonly(&self) -> bool { @@ -683,6 +727,14 @@ impl Tool for ExternalToolMux { } match route { Some(WorkspaceRoute::External { tool, .. }) => tool.call(input, context).await, + Some(WorkspaceRoute::Live { .. }) => { + self.selected(Some(context)) + .ok_or_else(|| { + BitFunError::tool(format!("tool '{}' is unavailable", self.name)) + })? + .call(input, context) + .await + } Some(WorkspaceRoute::Original { .. }) | None => { self.original() .ok_or_else(|| { @@ -704,19 +756,199 @@ impl Tool for ExternalToolMux { struct ExternalToolRouter { muxes: StdMutex>>, + live_candidates: + StdMutex>>>, mutation_gate: Mutex<()>, } +#[derive(Clone)] +struct LiveExternalToolCandidate { + tool: Arc, + native_agent_visible: bool, + candidate: ExternalToolConflictCandidate, +} + impl Default for ExternalToolRouter { fn default() -> Self { Self { muxes: StdMutex::new(BTreeMap::new()), + live_candidates: StdMutex::new(HashMap::new()), mutation_gate: Mutex::new(()), } } } impl ExternalToolRouter { + fn register_live_candidate( + &self, + workspace_key: &str, + tool: Arc, + provider_id: &str, + content_version: String, + native_agent_visible: bool, + ) { + let name = tool.name().to_string(); + let candidate_id = format!("external:live:{provider_id}:{name}"); + let candidate = ExternalToolConflictCandidate { + candidate_id: candidate_id.clone(), + display_name: name.clone(), + kind: ExternalToolConflictCandidateKind::External, + provider_id: provider_id.to_string(), + content_version, + source: None, + source_location: None, + }; + self.live_candidates + .lock() + .expect("external live tool candidate lock poisoned") + .entry(workspace_key.to_string()) + .or_default() + .entry(name) + .or_default() + .insert( + candidate_id, + LiveExternalToolCandidate { + tool, + native_agent_visible, + candidate, + }, + ); + } + + fn unregister_live_candidate(&self, workspace_key: &str, name: &str, provider_id: &str) { + let candidate_id = format!("external:live:{provider_id}:{name}"); + let mut by_workspace = self + .live_candidates + .lock() + .expect("external live tool candidate lock poisoned"); + let Some(by_name) = by_workspace.get_mut(workspace_key) else { + return; + }; + if let Some(candidates) = by_name.get_mut(name) { + candidates.remove(&candidate_id); + if candidates.is_empty() { + by_name.remove(name); + } + } + if by_name.is_empty() { + by_workspace.remove(workspace_key); + } + } + + fn live_candidates( + &self, + workspace_key: &str, + ) -> BTreeMap> { + self.live_candidates + .lock() + .expect("external live tool candidate lock poisoned") + .get(workspace_key) + .map(|by_name| { + by_name + .iter() + .map(|(name, candidates)| { + (name.clone(), candidates.values().cloned().collect()) + }) + .collect() + }) + .unwrap_or_default() + } + + async fn apply_initial_live_candidate_route( + &self, + workspace_key: &str, + name: &str, + tool: Arc, + native_agent_visible: bool, + ) { + let mut routes = self.workspace_routes(workspace_key); + if let Some(existing) = routes.get(name).cloned() { + match existing { + WorkspaceRoute::External { conflict, .. } => { + routes.insert(name.to_string(), WorkspaceRoute::Unavailable { conflict }); + self.apply_routes(workspace_key, routes).await; + } + WorkspaceRoute::Live { + conflict: Some(conflict), + .. + } => { + routes.insert( + name.to_string(), + WorkspaceRoute::Unavailable { + conflict: Some(conflict), + }, + ); + self.apply_routes(workspace_key, routes).await; + } + WorkspaceRoute::Live { conflict: None, .. } => { + routes.insert( + name.to_string(), + WorkspaceRoute::Live { + tool, + native_agent_visible, + conflict: None, + }, + ); + self.apply_routes(workspace_key, routes).await; + } + _ => {} + } + return; + } + let has_original = self.original_tool(name).await.is_some(); + let route = if tool.dynamic_provider_id() == Some("opencode-plugin") + && (!native_agent_visible || !has_original) + { + WorkspaceRoute::Live { + tool, + native_agent_visible, + conflict: None, + } + } else if has_original { + WorkspaceRoute::Original { conflict: None } + } else { + WorkspaceRoute::Live { + tool, + native_agent_visible, + conflict: None, + } + }; + routes.insert(name.to_string(), route); + self.apply_routes(workspace_key, routes).await; + } + + async fn withdraw_live_candidate_route(&self, workspace_key: &str, name: &str) { + let mut routes = self.workspace_routes(workspace_key); + let Some(route) = routes.remove(name) else { + return; + }; + match route { + WorkspaceRoute::Live { + conflict: Some(conflict), + .. + } => { + routes.insert( + name.to_string(), + WorkspaceRoute::Unavailable { + conflict: Some(conflict), + }, + ); + } + WorkspaceRoute::Live { conflict: None, .. } => { + if self.original_tool(name).await.is_some() { + routes.insert( + name.to_string(), + WorkspaceRoute::Original { conflict: None }, + ); + } + } + other => { + routes.insert(name.to_string(), other); + } + } + self.apply_routes(workspace_key, routes).await; + } + fn known_name(&self, tool_name: &str) -> Option { self.muxes .lock() @@ -837,6 +1069,26 @@ impl ExternalToolRouter { return tool; } mux.replace_original(Some(tool)); + let mut routes = mux + .routes + .write() + .expect("external tool route lock poisoned"); + for route in routes.values_mut() { + match route { + WorkspaceRoute::Live { conflict: None, .. } => { + *route = WorkspaceRoute::Original { conflict: None }; + } + WorkspaceRoute::Live { + conflict: Some(conflict), + .. + } => { + *route = WorkspaceRoute::Unavailable { + conflict: Some(conflict.clone()), + }; + } + _ => {} + } + } routed } @@ -895,6 +1147,23 @@ impl ExternalToolRouter { } } + fn resolve_registered_tool_for_context( + &self, + tool: Arc, + context: &ToolUseContext, + ) -> Option> { + let mux = self + .muxes + .lock() + .expect("external tool router lock poisoned") + .get(tool.name()) + .cloned(); + match mux { + Some(mux) => mux.selected(Some(context)), + None => Some(tool), + } + } + fn workspace_routes(&self, workspace_key: &str) -> BTreeMap { self.muxes .lock() @@ -916,6 +1185,41 @@ pub(crate) fn intercept_external_tool_registry_registration(tool: Arc) router().intercept_registration(tool) } +pub(crate) async fn register_live_external_tool_candidate( + workspace_root: &Path, + tool: Arc, + provider_id: &str, + content_version: String, + native_agent_visible: bool, +) { + let workspace_key = workspace_route_key(Some(workspace_root)); + router().register_live_candidate( + &workspace_key, + tool.clone(), + provider_id, + content_version, + native_agent_visible, + ); + let name = tool.name().to_string(); + router() + .apply_initial_live_candidate_route(&workspace_key, &name, tool, native_agent_visible) + .await; + crate::external_sources::notify_external_tool_registry_changed(); +} + +pub(crate) async fn unregister_live_external_tool_candidate( + workspace_root: &Path, + name: &str, + provider_id: &str, +) { + let workspace_key = workspace_route_key(Some(workspace_root)); + router().unregister_live_candidate(&workspace_key, name, provider_id); + router() + .withdraw_live_candidate_route(&workspace_key, name) + .await; + crate::external_sources::notify_external_tool_registry_changed(); +} + pub(crate) fn detach_external_tool_mcp_server(server_id: &str) -> Vec> { router().detach_mcp_server(server_id) } @@ -931,6 +1235,13 @@ pub(crate) fn resolve_external_tool_for_workspace( router().resolve_registered_tool(tool, workspace_root) } +pub(crate) fn resolve_external_tool_for_context( + tool: Arc, + context: &ToolUseContext, +) -> Option> { + router().resolve_registered_tool_for_context(tool, context) +} + pub(crate) fn external_tool_route_root( workspace_root: Option<&Path>, is_remote: bool, @@ -1328,6 +1639,7 @@ pub(super) async fn reconcile_external_tools( let workspace_key = workspace_route_key(workspace_root); let snapshot = control_plane.tools(|coordinator| coordinator.snapshot()); let mut state = ExternalToolProductState::default(); + let mut live_candidates_by_name = router().live_candidates(&workspace_key); let source_by_key = snapshot .sources .iter() @@ -1342,11 +1654,12 @@ pub(super) async fn reconcile_external_tools( .or_default() .push(tool); } - let discovered_names = target_groups + let mut discovered_names = target_groups .values() .flatten() .map(|tool| tool.name.clone()) .collect::>(); + discovered_names.extend(live_candidates_by_name.keys().cloned()); let mut names_to_quiesce = BTreeSet::new(); let mut preapproved_runtime_targets = BTreeSet::new(); for (target_id, definitions) in &target_groups { @@ -1842,12 +2155,18 @@ pub(super) async fn reconcile_external_tools( .await; let conflict_domain = workspace_conflict_domain(execution_domain_id, &workspace_key); - let mut names_by_normalized = BTreeMap::>::new(); + 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()); + .insert(name.clone()); + } + for name in live_candidates_by_name.keys() { + names_by_normalized + .entry(name.clone()) + .or_default() + .insert(name.clone()); } let conflict_prefix = format!("external_tool:{conflict_domain}:"); for conflict_key in decisions.conflict_choices.keys() { @@ -1861,7 +2180,7 @@ pub(super) async fn reconcile_external_tools( .entry(normalized_name.to_string()) .or_default(); if names.is_empty() { - names.push( + names.insert( router() .known_name(normalized_name) .unwrap_or_else(|| normalized_name.to_string()), @@ -1874,8 +2193,9 @@ pub(super) async fn reconcile_external_tools( let external_candidates = conflict_candidates_by_name .remove(&name) .unwrap_or_default(); + let live_candidates = live_candidates_by_name.remove(&name).unwrap_or_default(); let original = router().original_tool(&name).await; - if external_candidates.is_empty() && original.is_none() { + if external_candidates.is_empty() && live_candidates.is_empty() && original.is_none() { continue; } let mut candidates = Vec::new(); @@ -1894,6 +2214,11 @@ pub(super) async fn reconcile_external_tools( source_location: Some(definition.module_path.clone()), }); } + candidates.extend( + live_candidates + .iter() + .map(|candidate| candidate.candidate.clone()), + ); let has_conflict_history = if external_candidates.is_empty() { tool_conflict_history_requires_fail_closed( @@ -1916,6 +2241,15 @@ pub(super) async fn reconcile_external_tools( } }); routes.insert(name, route); + } else if let Some(candidate) = live_candidates.first() { + routes.insert( + name, + WorkspaceRoute::Live { + tool: candidate.tool.clone(), + native_agent_visible: candidate.native_agent_visible, + conflict: None, + }, + ); } continue; } @@ -1930,10 +2264,15 @@ pub(super) async fn reconcile_external_tools( ) }), ); - let external_candidate_ids = external_candidates + let mut external_candidate_ids = external_candidates .iter() .map(ExternalToolDefinition::candidate_id) .collect::>(); + external_candidate_ids.extend( + live_candidates + .iter() + .map(|candidate| candidate.candidate.candidate_id.clone()), + ); let (selected, route_choice) = resolve_conflict_route_choice( &conflict_key, &candidates, @@ -1947,15 +2286,25 @@ pub(super) async fn reconcile_external_tools( }); 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 { + if let Some(tool) = loaded_by_candidate_id.get(&candidate_id).cloned() { + WorkspaceRoute::External { tool, conflict: conflict.clone(), - }, - ) + } + } else if let Some(candidate) = live_candidates + .iter() + .find(|candidate| candidate.candidate.candidate_id == candidate_id) + { + WorkspaceRoute::Live { + tool: candidate.tool.clone(), + native_agent_visible: candidate.native_agent_visible, + conflict: conflict.clone(), + } + } else { + WorkspaceRoute::Unavailable { + conflict: conflict.clone(), + } + } } ConflictRouteChoice::Original => WorkspaceRoute::Original { conflict: conflict.clone(), @@ -2134,6 +2483,61 @@ mod tests { } } + #[cfg(feature = "opencode-plugin-host")] + struct PluginTestTool { + name: String, + } + + #[cfg(feature = "opencode-plugin-host")] + #[async_trait] + impl Tool for PluginTestTool { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> BitFunResult { + Ok("plugin test tool".to_string()) + } + + fn short_description(&self) -> String { + "plugin test tool".to_string() + } + + fn input_schema(&self) -> Value { + serde_json::json!({ "type": "object" }) + } + + fn dynamic_provider_id(&self) -> Option<&str> { + Some("opencode-plugin") + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(Vec::new()) + } + } + + #[cfg(feature = "opencode-plugin-host")] + fn local_tool_context(workspace_root: &Path, runtime_agent_key: &str) -> ToolUseContext { + crate::agentic::tools::tool_context_runtime::build_tool_description_context( + runtime_agent_key, + Some(&crate::agentic::WorkspaceBinding::new( + None, + workspace_root.to_path_buf(), + )), + None, + None, + None, + None, + None, + &Default::default(), + &Default::default(), + ) + } + fn candidate( id: &str, kind: ExternalToolConflictCandidateKind, @@ -2258,6 +2662,168 @@ mod tests { assert!(has_tool_conflict_history(&choices, "domain", "read")); } + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn live_plugin_route_is_visible_only_to_opencode_plugin_agents() { + let workspace = std::env::current_dir().expect("absolute workspace"); + let tool_name = "plugin_agent_scope_contract".to_string(); + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let plugin: Arc = Arc::new(PluginTestTool { + name: tool_name.clone(), + }); + let mux = ExternalToolMux::new(tool_name, Some(original.clone())); + mux.set_route( + workspace_route_key(Some(&workspace)), + WorkspaceRoute::Live { + tool: plugin.clone(), + native_agent_visible: false, + conflict: None, + }, + ); + let router = ExternalToolRouter::default(); + router + .muxes + .lock() + .expect("router lock") + .insert(mux.name.clone(), Arc::new(mux)); + + let plugin_context = local_tool_context( + &workspace, + "external_subagent_runtime:opencode-plugin:generation-agent", + ); + assert!(Arc::ptr_eq( + &router + .resolve_registered_tool_for_context(original.clone(), &plugin_context) + .expect("plugin route"), + &plugin + )); + + for runtime_agent_key in [ + "Agentic", + "Plan", + "external_subagent_runtime:other-provider:agent", + "external_subagent_runtime:opencode:agent", + ] { + let context = local_tool_context(&workspace, runtime_agent_key); + assert!(Arc::ptr_eq( + &router + .resolve_registered_tool_for_context(original.clone(), &context) + .expect("native fallback"), + &original + )); + } + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn plugin_only_live_route_is_visible_to_native_agents() { + let workspace = std::env::current_dir().expect("absolute workspace"); + let tool_name = "plugin_only_native_scope_contract".to_string(); + let plugin: Arc = Arc::new(PluginTestTool { + name: tool_name.clone(), + }); + let mux = Arc::new(ExternalToolMux::new(tool_name, None)); + mux.set_route( + workspace_route_key(Some(&workspace)), + WorkspaceRoute::Live { + tool: plugin.clone(), + native_agent_visible: true, + conflict: None, + }, + ); + let router = ExternalToolRouter::default(); + router + .muxes + .lock() + .expect("router lock") + .insert(mux.name.clone(), mux.clone()); + let registered: Arc = mux; + + let context = local_tool_context(&workspace, "Agentic"); + assert!(Arc::ptr_eq( + &router + .resolve_registered_tool_for_context(registered, &context) + .expect("plugin-only route"), + &plugin + )); + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn selected_tool_only_plugin_route_overrides_native_tool() { + let workspace = std::env::current_dir().expect("absolute workspace"); + let tool_name = "selected_plugin_tool_only_conflict_contract".to_string(); + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let plugin: Arc = Arc::new(PluginTestTool { + name: tool_name.clone(), + }); + let mux = ExternalToolMux::new(tool_name, Some(original.clone())); + mux.set_route( + workspace_route_key(Some(&workspace)), + WorkspaceRoute::Live { + tool: plugin.clone(), + native_agent_visible: true, + conflict: None, + }, + ); + let router = ExternalToolRouter::default(); + router + .muxes + .lock() + .expect("router lock") + .insert(mux.name.clone(), Arc::new(mux)); + + let context = local_tool_context(&workspace, "Agentic"); + assert!(Arc::ptr_eq( + &router + .resolve_registered_tool_for_context(original, &context) + .expect("selected plugin route"), + &plugin + )); + } + + #[cfg(feature = "opencode-plugin-host")] + #[tokio::test] + async fn initial_tool_only_plugin_collision_keeps_native_tool() { + let workspace = std::env::current_dir().expect("absolute workspace"); + let workspace_key = workspace_route_key(Some(&workspace)); + let tool_name = "initial_plugin_tool_only_conflict_contract".to_string(); + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let plugin: Arc = Arc::new(PluginTestTool { + name: tool_name.clone(), + }); + let mux = Arc::new(ExternalToolMux::new( + tool_name.clone(), + Some(original.clone()), + )); + let router = ExternalToolRouter::default(); + router + .muxes + .lock() + .expect("router lock") + .insert(tool_name.clone(), mux.clone()); + + router + .apply_initial_live_candidate_route(&workspace_key, &tool_name, plugin, true) + .await; + + assert!(matches!( + router.workspace_routes(&workspace_key).get(&tool_name), + Some(WorkspaceRoute::Original { conflict: None }) + )); + let context = local_tool_context(&workspace, "Agentic"); + assert!(Arc::ptr_eq( + &mux.selected(Some(&context)).expect("native route"), + &original + )); + } + #[tokio::test] async fn concurrent_workspace_routes_install_one_shared_mux() { let router = Arc::new(ExternalToolRouter::default()); @@ -2292,6 +2858,228 @@ mod tests { .unregister_tool(&tool_name); } + #[tokio::test] + async fn live_plugin_conflict_preserves_builtin_until_selected() { + use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalMcpRevisionKey, ExternalSourceContext, + }; + + let directory = tempfile::tempdir().expect("temporary workspace"); + let workspace_key = workspace_route_key(Some(directory.path())); + let tool_name = format!("live_plugin_conflict_{}", std::process::id()); + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + get_global_tool_registry() + .write() + .await + .register_tool_without_external_source_notification(original.clone()); + let live: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let candidate_id = format!("external:live:opencode-plugin:{tool_name}"); + let control_plane = Arc::new( + ExternalSourceControlPlane::new( + ExternalSourceContext { + workspace_root: Some(directory.path().to_path_buf()), + execution_domain_id: ExecutionDomainId::new("test-domain").unwrap(), + }, + ExternalMcpRevisionKey::new([3; 32]), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .unwrap(), + ); + router().register_live_candidate( + &workspace_key, + live.clone(), + "opencode-plugin", + "plugin-v1".to_string(), + true, + ); + let empty_ecosystems = BTreeSet::new(); + let empty_strings = BTreeSet::new(); + let empty_map = BTreeMap::new(); + + let unresolved = reconcile_external_tools( + Some(directory.path()), + "test-domain", + &control_plane, + ExternalToolDecisions { + active_ecosystems: &empty_ecosystems, + approved_targets: &empty_strings, + declined_decisions_by_approval: &empty_map, + conflict_choices: &empty_map, + }, + &empty_strings, + ) + .await; + assert_eq!(unresolved.conflicts.len(), 1); + assert!(unresolved.conflicts[0] + .candidates + .iter() + .any(|candidate| candidate.candidate_id == candidate_id)); + let mux = router() + .muxes + .lock() + .expect("router lock") + .get(&tool_name) + .cloned() + .expect("installed mux"); + assert!(Arc::ptr_eq( + &mux.selected_for_workspace(Some(directory.path())).unwrap(), + &original + )); + + let choices = + BTreeMap::from([(unresolved.conflicts[0].conflict_key.clone(), candidate_id)]); + let selected = reconcile_external_tools( + Some(directory.path()), + "test-domain", + &control_plane, + ExternalToolDecisions { + active_ecosystems: &empty_ecosystems, + approved_targets: &empty_strings, + declined_decisions_by_approval: &empty_map, + conflict_choices: &choices, + }, + &empty_strings, + ) + .await; + assert_eq!( + selected.conflicts[0].selected_candidate_id, + Some(format!("external:live:opencode-plugin:{tool_name}")) + ); + assert!(Arc::ptr_eq( + &mux.selected_for_workspace(Some(directory.path())).unwrap(), + &live + )); + + router().unregister_live_candidate(&workspace_key, &tool_name, "opencode-plugin"); + let withdrawn = reconcile_external_tools( + Some(directory.path()), + "test-domain", + &control_plane, + ExternalToolDecisions { + active_ecosystems: &empty_ecosystems, + approved_targets: &empty_strings, + declined_decisions_by_approval: &empty_map, + conflict_choices: &choices, + }, + &empty_strings, + ) + .await; + assert_eq!(withdrawn.conflicts.len(), 1); + assert_eq!(withdrawn.conflicts[0].selected_candidate_id, None); + assert!(mux.selected_for_workspace(Some(directory.path())).is_none()); + + router().apply_routes(&workspace_key, 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 late_local_registration_pauses_an_active_live_plugin_route() { + let router = ExternalToolRouter::default(); + let tool_name = "late_local_plugin_conflict".to_string(); + let live: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let mux = Arc::new(ExternalToolMux::new(tool_name.clone(), None)); + mux.set_route( + "workspace".to_string(), + WorkspaceRoute::Live { + tool: live, + native_agent_visible: true, + conflict: None, + }, + ); + router + .muxes + .lock() + .expect("router lock") + .insert(tool_name.clone(), mux.clone()); + + let local: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let routed = router.intercept_registration(local.clone()); + + let expected_mux: Arc = mux; + assert!(Arc::ptr_eq(&routed, &expected_mux)); + assert!(Arc::ptr_eq( + &router + .original_tool(&tool_name) + .await + .expect("local candidate"), + &local + )); + assert!(matches!( + router.workspace_routes("workspace").get(&tool_name), + Some(WorkspaceRoute::Original { conflict: None }) + )); + } + + #[tokio::test] + async fn late_live_registration_pauses_an_active_external_route() { + let router = ExternalToolRouter::default(); + let tool_name = "late_plugin_external_conflict".to_string(); + 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"}), + }, + ecosystem_id: "test".to_string(), + provider_id: "test-provider".to_string(), + runtime_target_id: "target".to_string(), + load_generation: 1, + 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 mux = Arc::new(ExternalToolMux::new(tool_name.clone(), None)); + mux.set_route( + "workspace".to_string(), + WorkspaceRoute::External { + tool: external, + conflict: None, + }, + ); + router + .muxes + .lock() + .expect("router lock") + .insert(tool_name.clone(), mux); + + let live: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + router + .apply_initial_live_candidate_route("workspace", &tool_name, live, true) + .await; + + assert!(matches!( + router.workspace_routes("workspace").get(&tool_name), + Some(WorkspaceRoute::Unavailable { conflict: None }) + )); + } + #[tokio::test] async fn last_route_removal_keeps_concurrent_registration_behind_the_mux() { let tool_name = "external_router_last_route_registration_contract".to_string(); diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 080b66a3a7..1fc2c50c42 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -37,6 +37,10 @@ pub mod native_hooks; #[cfg(all(test, feature = "agent-runtime"))] mod native_hooks_tests; #[cfg(feature = "opencode-plugin-host")] +mod plugin_config_projection; +#[cfg(feature = "opencode-plugin-host")] +mod plugin_hook_bridge; +#[cfg(feature = "opencode-plugin-host")] pub mod plugin_host; #[cfg(feature = "opencode-plugin-host")] mod plugin_host_http; diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index e2aa8e70a4..513ec20d96 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -20,21 +20,209 @@ use crate::infrastructure::try_get_path_manager_arc; use crate::service::config::get_global_config_service; pub use crate::service::config::types::AgentHooksConfig; +use async_trait::async_trait; use bitfun_agent_runtime::native_hooks::{ AgentHookEngine, AgentHookEvent, AgentHookEventPayload, AgentHookMatcher, AgentHookOutcome, AgentHookPayload, AgentHookPayloadCommon, AgentHookPermissionMode, AgentHookPermissionOutcome, - AgentHookScope, AgentHookSettings, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, + AgentHookScope, AgentHookSettings, AgentHookSettingsLayer, BuiltinHookExecutor, HookCall, + HookCallPayload, HookHandler, HookHandlerResult, RuntimeHookKind, RuntimeHookPlan, + RuntimeHookRegistration, RuntimeHookRegistry, RuntimeHookSource, MAX_HOOKS_FILE_BYTES, +}; +use bitfun_agent_runtime::post_call_hooks::{ + resolve_deep_review_shared_context_tool_use, DeepReviewSharedContextToolUseFacts, }; use dashmap::DashMap; use log::{debug, info, warn}; use serde_json::Value; -use std::collections::BTreeMap; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -const MAX_CACHED_WORKSPACE_ENGINES: usize = 32; +const MAX_CACHED_WORKSPACE_HOOK_SOURCES: usize = 32; const MAX_PENDING_CONTEXT_SESSIONS: usize = 1024; +pub(crate) fn new_runtime_hook_registry() -> RuntimeHookRegistry { + runtime_hook_registry() +} + +pub(crate) fn runtime_hook_registry() -> RuntimeHookRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY + .get_or_init(|| { + let registry = RuntimeHookRegistry::default(); + registry + .register_batch(vec![deep_review_builtin_registration()]) + .expect("deep review builtin registration must be valid"); + registry + }) + .clone() +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) fn plugin_hook_registry(_workspace_scope: &str) -> RuntimeHookRegistry { + runtime_hook_registry() +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) async fn dispatch_plugin_hook( + workspace_scope: &str, + generation: Option<&bitfun_agent_runtime::native_hooks::PluginHookGenerationIdentity>, + hook_name: &str, + input: Value, + output: Value, +) -> bitfun_agent_runtime::native_hooks::PluginHookDispatchResult { + AgentHookEngine::with_registry(plugin_hook_registry(workspace_scope)) + .dispatch_plugin_hook_for_generation( + Some(workspace_scope), + generation, + hook_name, + input, + output, + ) + .await +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) async fn dispatch_plugin_tool_before( + workspace_scope: &str, + tool_name: &str, + session_id: Option<&str>, + call_id: Option<&str>, + runtime_agent_key: Option<&str>, + args: Value, +) -> Result, String> { + let generation = match runtime_agent_key { + Some(runtime_agent_key) => { + let generation = crate::plugin_host::plugin_hook_generation_for_agent( + workspace_scope, + runtime_agent_key, + ) + .await; + if crate::plugin_config_projection::is_plugin_agent_runtime_key(runtime_agent_key) + && generation.is_none() + { + return Ok(None); + } + generation + } + None => None, + }; + let result = dispatch_plugin_hook( + workspace_scope, + generation.as_ref(), + "tool.execute.before", + serde_json::json!({ + "tool": tool_name, + "sessionID": session_id, + "callID": call_id, + }), + serde_json::json!({ "args": args }), + ) + .await; + for warning in &result.warnings { + warn!("OpenCode plugin hook warning (tool.execute.before): {warning}"); + } + if let Some(failure) = result.failure { + return Err(failure); + } + Ok(result + .output + .get("args") + .cloned() + .filter(|updated| updated != &serde_json::Value::Null)) +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) async fn dispatch_plugin_tool_after( + workspace_scope: &str, + tool_name: &str, + session_id: Option<&str>, + call_id: Option<&str>, + runtime_agent_key: Option<&str>, + args: Value, + title: String, + output: String, + metadata: Value, +) -> Result, String> { + let generation = match runtime_agent_key { + Some(runtime_agent_key) => { + let generation = crate::plugin_host::plugin_hook_generation_for_agent( + workspace_scope, + runtime_agent_key, + ) + .await; + if crate::plugin_config_projection::is_plugin_agent_runtime_key(runtime_agent_key) + && generation.is_none() + { + return Ok(None); + } + generation + } + None => None, + }; + let result = dispatch_plugin_hook( + workspace_scope, + generation.as_ref(), + "tool.execute.after", + serde_json::json!({ + "tool": tool_name, + "sessionID": session_id, + "callID": call_id, + "args": args, + }), + serde_json::json!({ + "title": title, + "output": output, + "metadata": metadata, + }), + ) + .await; + for warning in &result.warnings { + warn!("OpenCode plugin hook warning (tool.execute.after): {warning}"); + } + if let Some(failure) = result.failure { + return Err(failure); + } + match serde_json::from_value::(result.output) { + Ok(output) => Ok(Some(output)), + Err(error) => Err(format!("Invalid tool.execute.after output: {error}")), + } +} + +#[cfg(feature = "opencode-plugin-host")] +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PluginToolAfterOutput { + pub(crate) title: String, + pub(crate) output: String, + pub(crate) metadata: Value, +} + +#[cfg(feature = "opencode-plugin-host")] +impl PluginToolAfterOutput { + pub(crate) fn into_model_output(self) -> String { + let Self { + title, + output, + metadata, + } = self; + // The Host carries title and metadata through the complete ordered + // OpenCode Hook chain. BitFun's stable ToolResult contract currently + // has one mutable presentation field: the model-visible output. Keep + // the raw result immutable and avoid inventing a second persistence/UI + // schema until a product consumer for these two presentation facts is + // specified. + drop((title, metadata)); + output + } +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) fn clear_plugin_hook_workspace(workspace_scope: &str) { + runtime_hook_registry().clear_source_workspace(RuntimeHookSource::Plugin, workspace_scope); +} + /// Everything a dispatch site knows about the running session. #[derive(Debug, Clone, Copy)] pub struct NativeHookSessionFacts<'a> { @@ -344,10 +532,222 @@ pub fn clear_session_hook_state(session_id: &str) { pending_session_context().remove(session_id); } +/// Built-in DeepReview shared-context measurement hook. +/// +/// Registered as a SuccessfulToolPostCall builtin so the shared hook +/// registry owns it alongside command hooks, instead of a hard-coded +/// function call in the tool pipeline. +struct DeepReviewSharedContextExecutor; + +#[async_trait] +impl BuiltinHookExecutor for DeepReviewSharedContextExecutor { + async fn execute(&self, call: &HookCall) -> HookHandlerResult { + let HookCallPayload::ToolUse { + name, + input, + custom_data, + agent_type, + } = &call.payload + else { + return HookHandlerResult::default(); + }; + let facts = DeepReviewSharedContextToolUseFacts { + tool_name: name.as_str(), + input, + custom_data, + workspace_root: call.workspace_root.as_deref(), + is_remote: call.is_remote, + agent_type: agent_type.as_deref(), + }; + if let Some(record) = resolve_deep_review_shared_context_tool_use(facts) { + crate::agentic::deep_review_policy::record_deep_review_shared_context_tool_use( + &record.parent_turn_id, + &record.subagent_type, + &record.tool_name, + &record.measured_path, + ); + } + HookHandlerResult::default() + } +} + +fn deep_review_builtin_registration() -> RuntimeHookRegistration { + let plan = RuntimeHookPlan::new( + "deep-review.shared-context", + RuntimeHookKind::SuccessfulToolPostCall, + RuntimeHookSource::Builtin { priority: 0 }, + ); + RuntimeHookRegistration::new( + plan, + HookHandler::Builtin { + executor: Arc::new(DeepReviewSharedContextExecutor), + }, + AgentHookMatcher::Any, + ) +} + +fn canonical_hook_workspace_scope(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let mut scope = crate::agentic::workspace::canonical_local_workspace_path(path) + .to_string_lossy() + .replace('\\', "/"); + #[cfg(windows)] + scope.make_ascii_lowercase(); + Some(scope) +} + +fn command_registration_id( + source: RuntimeHookSource, + workspace_scope: Option<&str>, + original_id: &str, +) -> String { + let identity = format!( + "{}\0{}\0{}", + source, + workspace_scope.unwrap_or(""), + original_id + ); + format!( + "command.{}.{}", + source, + hex::encode(Sha256::digest(identity.as_bytes())) + ) +} + +fn registrations_for_source( + settings: AgentHookSettings, + source: RuntimeHookSource, + workspace_scope: Option<&str>, +) -> Vec { + settings + .registrations() + .into_iter() + .filter(|entry| match source { + RuntimeHookSource::UserCommand => entry.plan.source() == RuntimeHookSource::UserCommand, + RuntimeHookSource::ProjectCommand => { + entry.plan.source() == RuntimeHookSource::ProjectCommand + } + RuntimeHookSource::ImportedCommand => true, + _ => false, + }) + .map(|mut entry| { + let original_id = entry.plan.id().to_string(); + entry.plan = entry + .plan + .with_source(source) + .with_id(command_registration_id( + source, + workspace_scope, + &original_id, + )); + entry.workspace_scope = workspace_scope.map(str::to_string); + entry + }) + .collect() +} + +fn publish_command_registrations( + registry: &RuntimeHookRegistry, + workspace_scope: Option<&str>, + manual_settings: AgentHookSettings, + imported_settings: AgentHookSettings, +) -> Result<(), bitfun_agent_runtime::native_hooks::RuntimeHookRegistryError> { + let manual = manual_settings.registrations(); + let user_entries = manual + .iter() + .filter(|entry| entry.plan.source() == RuntimeHookSource::UserCommand) + .cloned() + .map(|mut entry| { + let original_id = entry.plan.id().to_string(); + entry.plan = entry.plan.with_id(command_registration_id( + RuntimeHookSource::UserCommand, + None, + &original_id, + )); + entry + }) + .collect(); + let project_entries = manual + .into_iter() + .filter(|entry| entry.plan.source() == RuntimeHookSource::ProjectCommand) + .map(|mut entry| { + let original_id = entry.plan.id().to_string(); + entry.plan = entry.plan.with_id(command_registration_id( + RuntimeHookSource::ProjectCommand, + workspace_scope, + &original_id, + )); + entry.workspace_scope = workspace_scope.map(str::to_string); + entry + }) + .collect(); + let imported_entries = registrations_for_source( + imported_settings, + RuntimeHookSource::ImportedCommand, + workspace_scope, + ); + + registry.replace_command_source(RuntimeHookSource::UserCommand, None, user_entries)?; + registry.replace_command_source( + RuntimeHookSource::ProjectCommand, + workspace_scope, + project_entries, + )?; + registry.replace_command_source( + RuntimeHookSource::ImportedCommand, + workspace_scope, + imported_entries, + ) +} + +/// Dispatch SuccessfulToolPostCall builtin hooks (currently the DeepReview +/// shared-context measurement) for one successful tool call. Command hooks +/// are not dispatched here because the tool pipeline owns post-call context. +pub async fn dispatch_successful_tool_post_call( + workspace_root: Option<&Path>, + is_remote: bool, + tool_name: &str, + input: &Value, + custom_data: &HashMap, + agent_type: Option<&str>, +) { + let config = hooks_config().await; + let Some(engine) = engine_for(workspace_root, config.project_hooks_enabled).await else { + return; + }; + let call = HookCall { + kind: RuntimeHookKind::SuccessfulToolPostCall, + cwd: workspace_root.map(Path::to_path_buf).unwrap_or_default(), + session_id: None, + turn_id: None, + workspace_root: workspace_root.map(Path::to_path_buf), + is_remote, + model: None, + bypass_permissions: false, + payload: HookCallPayload::ToolUse { + name: tool_name.to_string(), + input: input.clone(), + custom_data: custom_data.clone(), + agent_type: agent_type.map(str::to_string), + }, + }; + let _ = engine + .dispatch_call( + workspace_root + .and_then(canonical_hook_workspace_scope) + .as_deref(), + &call, + ) + .await; +} + struct PreparedDispatch<'a> { - engine: Arc, + engine: AgentHookEngine, facts: NativeHookSessionFacts<'a>, cwd: PathBuf, + workspace_scope: Option, } impl PreparedDispatch<'_> { @@ -368,7 +768,10 @@ impl PreparedDispatch<'_> { event, }; let event_name = payload.event(); - let outcome = self.engine.dispatch(&payload, &self.cwd).await; + let outcome = self + .engine + .dispatch_for_workspace(&payload, &self.cwd, self.workspace_scope.as_deref()) + .await; for warning in &outcome.warnings { warn!("Agent hook warning ({event_name}): {warning}"); } @@ -397,7 +800,10 @@ async fn prepare<'a>( return None; } let engine = engine_for(facts.workspace_root, config.project_hooks_enabled).await?; - if !engine.has_rules(event) { + let workspace_scope = facts + .workspace_root + .and_then(canonical_hook_workspace_scope); + if !engine.has_rules_for_workspace(event, workspace_scope.as_deref()) { return None; } let cwd = facts @@ -405,7 +811,12 @@ async fn prepare<'a>( .map(Path::to_path_buf) .or_else(|| std::env::current_dir().ok()) .unwrap_or_default(); - Some(PreparedDispatch { engine, facts, cwd }) + Some(PreparedDispatch { + engine, + facts, + cwd, + workspace_scope, + }) } /// Dot-path of the hook gates inside the settings document. Config paths @@ -447,17 +858,16 @@ fn fingerprint(path: PathBuf) -> HookFileFingerprint { } } -struct CachedHookEngine { - engine: Arc, +struct CachedHookSourceState { fingerprints: Vec, project_hooks_enabled: bool, imported_generation: u64, } -type EngineCache = tokio::sync::Mutex, CachedHookEngine>>; +type HookSourceCache = tokio::sync::Mutex, CachedHookSourceState>>; -fn engine_cache() -> &'static EngineCache { - static CACHE: OnceLock = OnceLock::new(); +fn hook_source_cache() -> &'static HookSourceCache { + static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| tokio::sync::Mutex::new(BTreeMap::new())) } @@ -541,12 +951,9 @@ pub(crate) fn build_engine(paths: &[(AgentHookScope, PathBuf)]) -> AgentHookEngi async fn engine_for( workspace_root: Option<&Path>, project_hooks_enabled: bool, -) -> Option> { +) -> Option { let key = workspace_root.map(Path::to_path_buf); let paths = hook_settings_paths(workspace_root, project_hooks_enabled); - if paths.is_empty() { - return None; - } let fingerprints = paths .iter() .map(|(_, path)| fingerprint(path.clone())) @@ -568,15 +975,15 @@ async fn engine_for( } }; { - let cache = engine_cache().lock().await; + let cache = hook_source_cache().lock().await; if let Some(cached) = cache.get(&key) { - if let Some(engine) = reusable_cached_engine( + if reusable_cached_hook_source( cached, &fingerprints, project_hooks_enabled, imported_generation, ) { - return Some(engine); + return Some(AgentHookEngine::with_registry(runtime_hook_registry())); } } } @@ -601,41 +1008,53 @@ async fn engine_for( for message in &skipped { warn!("{message}"); } - let layers = ordered_layers(manual_layers, imported_layers); - let (settings, issues) = AgentHookSettings::from_layers(&layers); - for issue in &issues { + let (manual_settings, manual_issues) = AgentHookSettings::from_layers(&manual_layers); + let (imported_settings, imported_issues) = AgentHookSettings::from_layers(&imported_layers); + for issue in manual_issues.iter().chain(imported_issues.iter()) { warn!("Agent hook configuration issue: {issue}"); } - let engine = Arc::new(AgentHookEngine::new(settings)); - let mut cache = engine_cache().lock().await; - if cache.len() >= MAX_CACHED_WORKSPACE_ENGINES && !cache.contains_key(&key) { + let workspace_scope = workspace_root.and_then(canonical_hook_workspace_scope); + if let Err(error) = publish_command_registrations( + &runtime_hook_registry(), + workspace_scope.as_deref(), + manual_settings, + imported_settings, + ) { + warn!("Failed to publish agent hook registrations: {error}"); + return None; + } + let mut cache = hook_source_cache().lock().await; + if cache.len() >= MAX_CACHED_WORKSPACE_HOOK_SOURCES && !cache.contains_key(&key) { let oldest = cache.keys().next().cloned(); if let Some(oldest) = oldest { cache.remove(&oldest); + if let Some(scope) = oldest.as_deref().and_then(canonical_hook_workspace_scope) { + let registry = runtime_hook_registry(); + registry.clear_source_workspace(RuntimeHookSource::ProjectCommand, &scope); + registry.clear_source_workspace(RuntimeHookSource::ImportedCommand, &scope); + } } } cache.insert( key, - CachedHookEngine { - engine: Arc::clone(&engine), + CachedHookSourceState { fingerprints, project_hooks_enabled, imported_generation, }, ); - Some(engine) + Some(AgentHookEngine::with_registry(runtime_hook_registry())) } -fn reusable_cached_engine( - cached: &CachedHookEngine, +fn reusable_cached_hook_source( + cached: &CachedHookSourceState, fingerprints: &[HookFileFingerprint], project_hooks_enabled: bool, imported_generation: u64, -) -> Option> { - (cached.fingerprints == fingerprints +) -> bool { + cached.fingerprints == fingerprints && cached.project_hooks_enabled == project_hooks_enabled - && cached.imported_generation == imported_generation) - .then(|| Arc::clone(&cached.engine)) + && cached.imported_generation == imported_generation } #[cfg(test)] @@ -643,19 +1062,35 @@ mod cache_tests { use super::*; #[test] - fn imported_generation_replaces_the_next_engine_without_invalidating_a_captured_one() { - let captured = Arc::new(AgentHookEngine::new(Default::default())); - let cached = CachedHookEngine { - engine: Arc::clone(&captured), + fn runtime_hook_registry_handles_share_the_process_runtime_owner() { + let workspace_scope = format!("runtime-hook-registry-test-{}", uuid::Uuid::new_v4()); + let first = new_runtime_hook_registry(); + let second = runtime_hook_registry(); + + first.set_source_activation_for_workspace( + RuntimeHookSource::Plugin, + Some(&workspace_scope), + bitfun_agent_runtime::native_hooks::RuntimeHookActivation::Ready, + ); + + assert_eq!( + second + .source_activation_for_workspace(RuntimeHookSource::Plugin, Some(&workspace_scope)), + bitfun_agent_runtime::native_hooks::RuntimeHookActivation::Ready + ); + second.clear_source_workspace(RuntimeHookSource::Plugin, &workspace_scope); + } + + #[test] + fn imported_generation_invalidates_the_cached_hook_source_state() { + let cached = CachedHookSourceState { fingerprints: Vec::new(), project_hooks_enabled: false, imported_generation: 7, }; - let reused = reusable_cached_engine(&cached, &[], false, 7).unwrap(); - assert!(Arc::ptr_eq(&captured, &reused)); - assert!(reusable_cached_engine(&cached, &[], false, 8).is_none()); - assert_eq!(Arc::strong_count(&captured), 3); + assert!(reusable_cached_hook_source(&cached, &[], false, 7)); + assert!(!reusable_cached_hook_source(&cached, &[], false, 8)); } } diff --git a/src/crates/assembly/core/src/plugin_config_projection.rs b/src/crates/assembly/core/src/plugin_config_projection.rs new file mode 100644 index 0000000000..669e765e4a --- /dev/null +++ b/src/crates/assembly/core/src/plugin_config_projection.rs @@ -0,0 +1,1590 @@ +use crate::agentic::agents::{ + external_subagent_runtime_key, get_agent_registry, shared_coding_mode_tools, ExploreAgent, + ExternalProvidedAgent, ExternalSubagentModelBinding, ExternalSubagentRegistration, + ExternalSubagentRoute, +}; +use bitfun_product_domains::external_sources::EcosystemId; +use bitfun_product_domains::external_subagents::ExternalSubagentMode; +use bitfun_runtime_ports::{ + HookFunctionContributorOutcome, HookFunctionPluginIdentity, HookFunctionRegistrationBatch, + HookFunctionToolRegistration, PermissionConstraintLayer, PermissionEffect, PermissionRule, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock, RwLock}; + +const MAX_AGENT_ID_BYTES: usize = 128; +const MAX_DESCRIPTION_BYTES: usize = 4096; +const MAX_PROMPT_BYTES: usize = 1024 * 1024; +const MAX_PLUGIN_SKILL_ROOTS: usize = 64; +const MIN_AGENT_TEMPERATURE: f64 = 0.0; +const MAX_AGENT_TEMPERATURE: f64 = 2.0; +const OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER: &str = "opencode-plugin-config"; + +pub(crate) fn is_plugin_agent_runtime_key(runtime_agent_key: &str) -> bool { + runtime_agent_key.starts_with("external_subagent_runtime:opencode-plugin:") +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PluginIdentity { + id: Option, + spec: String, + entry: String, + index: usize, +} + +impl From<&HookFunctionPluginIdentity> for PluginIdentity { + fn from(value: &HookFunctionPluginIdentity) -> Self { + Self { + id: value.id.clone(), + spec: value.spec.clone(), + entry: value.entry.clone(), + index: value.index, + } + } +} + +impl PluginIdentity { + fn stable_key(&self) -> String { + format!("{}\n{}\n{}", self.spec, self.entry, self.index) + } + + fn label(&self) -> String { + self.id.clone().unwrap_or_else(|| self.spec.clone()) + } +} + +#[derive(Debug)] +struct ConfigContributor { + plugin: PluginIdentity, + outcome: ContributorOutcome, +} + +#[derive(Debug, Clone)] +struct ConfigContribution { + plugin: PluginIdentity, + outcome: ContributorOutcome, + config: Map, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ContributorOutcome { + Applied, + Failed, +} + +impl From for ContributorOutcome { + fn from(value: HookFunctionContributorOutcome) -> Self { + match value { + HookFunctionContributorOutcome::Applied => Self::Applied, + HookFunctionContributorOutcome::Failed => Self::Failed, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PluginSkillRootContribution { + pub(crate) path: PathBuf, + pub(crate) precedence: usize, +} + +#[derive(Debug, Clone)] +struct PublishedSkillGeneration { + generation_key: String, + workspace_roots: Vec, +} + +fn skill_generations() -> &'static RwLock> { + static GENERATIONS: OnceLock>> = + OnceLock::new(); + GENERATIONS.get_or_init(|| RwLock::new(HashMap::new())) +} + +pub(crate) struct PluginConfigProjectionPlan { + workspace_root: PathBuf, + generation_key: String, + registrations: Vec, + routes: BTreeMap, + runtime_agent_keys: BTreeSet, + workspace_skill_roots: Vec, + tool_runtime_agent_keys: BTreeMap<(PluginIdentity, String), BTreeSet>, +} + +impl PluginConfigProjectionPlan { + pub(crate) fn empty(workspace_root: &Path, generation_key: &str) -> Self { + Self { + workspace_root: workspace_root.to_path_buf(), + generation_key: generation_key.to_string(), + registrations: Vec::new(), + routes: BTreeMap::new(), + runtime_agent_keys: BTreeSet::new(), + workspace_skill_roots: Vec::new(), + tool_runtime_agent_keys: BTreeMap::new(), + } + } + + pub(crate) fn agent_runtime_keys(&self) -> BTreeSet { + self.runtime_agent_keys.clone() + } + + pub(crate) fn allowed_runtime_agent_keys_for_tool( + &self, + tool: &HookFunctionToolRegistration, + ) -> crate::BitFunResult> { + let plugin = tool + .plugin + .as_ref() + .map(PluginIdentity::from) + .ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool identity is missing".to_string()) + })?; + Ok(self + .tool_runtime_agent_keys + .get(&(plugin, tool.id.clone())) + .cloned() + .unwrap_or_default()) + } + + pub(crate) fn commit(self) { + get_agent_registry().replace_external_subagent_route_overlay( + &self.workspace_root, + OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER, + self.registrations, + self.routes, + ); + let mut generations = skill_generations() + .write() + .expect("plugin skill generation lock poisoned"); + generations.insert( + self.workspace_root, + PublishedSkillGeneration { + generation_key: self.generation_key, + workspace_roots: self.workspace_skill_roots, + }, + ); + } +} + +pub(crate) fn release_workspace(workspace_root: &Path) { + let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + get_agent_registry().release_external_subagent_route_overlay( + &workspace_root, + OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER, + ); + skill_generations() + .write() + .expect("plugin skill generation lock poisoned") + .remove(&workspace_root); +} + +pub(crate) fn release_workspace_generation( + workspace_root: &Path, + expected_generation_key: &str, +) -> bool { + let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + let mut generations = skill_generations() + .write() + .expect("plugin skill generation lock poisoned"); + if generations + .get(&workspace_root) + .is_none_or(|generation| generation.generation_key != expected_generation_key) + { + return false; + } + get_agent_registry().release_external_subagent_route_overlay( + &workspace_root, + OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER, + ); + generations.remove(&workspace_root); + true +} + +pub(crate) fn skill_roots_for_agent( + workspace_root: Option<&Path>, + _runtime_agent_key: Option<&str>, +) -> Vec { + let Some(workspace_root) = workspace_root else { + return Vec::new(); + }; + let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + skill_generations() + .read() + .expect("plugin skill generation lock poisoned") + .get(&workspace_root) + .map(|generation| generation.workspace_roots.clone()) + .unwrap_or_default() +} + +pub(crate) fn prepare( + workspace_root: &Path, + generation_key: &str, + initial_config: &Map, + registration_batch: &HookFunctionRegistrationBatch, +) -> crate::BitFunResult { + let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + let contributors = registration_batch + .config_contributors + .iter() + .map(|entry| ConfigContributor { + plugin: PluginIdentity::from(&entry.plugin), + outcome: entry.outcome.into(), + }) + .collect::>(); + if contributors.is_empty() { + return Ok(PluginConfigProjectionPlan::empty( + &workspace_root, + generation_key, + )); + } + let config = ®istration_batch.config; + let contributions = registration_batch + .config_contributions + .iter() + .map(|entry| ConfigContribution { + plugin: PluginIdentity::from(&entry.plugin), + outcome: entry.outcome.into(), + config: entry.config.clone(), + }) + .collect::>(); + let contributions = config_contribution_sequence(&contributions, &contributors, config)?; + let attribution = attribute_config(initial_config, &contributions, config, &workspace_root)?; + let final_agents = config_object_field(config, "agent")?; + let plugin_tools = plugin_tool_ids_by_owner(®istration_batch.tools)?; + let tool_owners = plugin_tools + .iter() + .flat_map(|(owner, tools)| tools.iter().cloned().map(|tool| (tool, owner.clone()))) + .collect::>(); + let all_plugin_tools = tool_owners.keys().cloned().collect::>(); + + let mut registrations = Vec::new(); + let mut routes = BTreeMap::new(); + let mut runtime_agent_keys = BTreeSet::new(); + let mut runtime_agent_keys_by_plugin = BTreeMap::>::new(); + let mut tool_runtime_agent_keys = BTreeMap::<(PluginIdentity, String), BTreeSet>::new(); + for (logical_id, value) in final_agents { + let Some(owner) = attribution.agent_owners.get(&logical_id) else { + continue; + }; + let definition = value.as_object().ok_or_else(|| { + crate::BitFunError::Validation(format!("Plugin agent '{logical_id}' must be an object")) + })?; + validate_agent_id(&logical_id)?; + let mode = parse_mode(definition.get("mode"), &logical_id)?; + let hidden = parse_hidden(definition.get("hidden"), &logical_id)?; + let temperature = parse_temperature(definition.get("temperature"), &logical_id)?; + let description = parse_description(definition.get("description"), owner)?; + let prompt = parse_prompt(definition.get("prompt"), &logical_id)?; + let mut eligible_tools = plugin_tools.get(owner).cloned().unwrap_or_default(); + if let Some(permission) = definition.get("permission").and_then(Value::as_object) { + for (tool, effect) in permission { + if !matches!(effect.as_str(), Some("allow" | "ask")) { + continue; + } + let Some(tool_owner) = tool_owners.get(tool) else { + continue; + }; + if attribution + .permission_owners + .get(&(logical_id.clone(), tool.clone())) + == Some(tool_owner) + { + eligible_tools.insert(tool.clone()); + } + } + } + let (permission_constraints, denied_plugin_tools) = + parse_permissions(definition.get("permission"), &all_plugin_tools, &logical_id)?; + let mut tools = native_tool_baseline(&logical_id, mode, &workspace_root); + let permitted_plugin_tools = eligible_tools + .iter() + .filter(|tool| !denied_plugin_tools.contains(*tool)) + .cloned() + .collect::>(); + tools.extend(permitted_plugin_tools.iter().cloned()); + // A plugin Tool intentionally shadows a same-name native candidate for + // this plugin Agent. Remove the earlier entry before the final stable + // de-duplication so the manifest still contains one model-facing name. + for plugin_tool in &permitted_plugin_tools { + if let Some(position) = tools.iter().position(|tool| tool == plugin_tool) { + tools.remove(position); + tools.push(plugin_tool.clone()); + } + } + tools.sort(); + tools.dedup(); + + let mut hasher = Sha256::new(); + hasher.update(generation_key.as_bytes()); + hasher.update([0]); + hasher.update(owner.stable_key().as_bytes()); + hasher.update([0]); + hasher.update(logical_id.as_bytes()); + hasher.update([0]); + hasher.update([u8::from(hidden)]); + hasher.update([0]); + if let Some(temperature) = temperature { + hasher.update(temperature.to_bits().to_le_bytes()); + } else { + hasher.update([0xff]); + } + let digest = hex::encode(hasher.finalize()); + let runtime_key = external_subagent_runtime_key(&format!("opencode-plugin:{digest}")); + let behavior_version = format!("sha256:{digest}"); + let agent = Arc::new(ExternalProvidedAgent::new( + runtime_key.clone(), + logical_id.clone(), + description, + prompt, + tools, + permission_constraints, + temperature, + false, + behavior_version, + )); + registrations.push(ExternalSubagentRegistration { + runtime_key: runtime_key.clone(), + logical_id: logical_id.clone(), + route_key: format!( + "opencode:{}:{}", + hex::encode(Sha256::digest(owner.stable_key().as_bytes())), + logical_id.to_ascii_lowercase() + ), + ecosystem_id: EcosystemId::new("opencode").map_err(|error| { + crate::BitFunError::Validation(format!("Invalid OpenCode ecosystem id: {error}")) + })?, + provider_label: owner.label(), + model_binding: ExternalSubagentModelBinding::InheritParent, + hidden, + mode, + agent, + }); + routes.insert( + logical_id, + ExternalSubagentRoute::External(runtime_key.clone()), + ); + runtime_agent_keys_by_plugin + .entry(owner.clone()) + .or_default() + .insert(runtime_key.clone()); + for tool in permitted_plugin_tools { + let Some(tool_owner) = tool_owners.get(&tool) else { + continue; + }; + tool_runtime_agent_keys + .entry((tool_owner.clone(), tool)) + .or_default() + .insert(runtime_key.clone()); + } + runtime_agent_keys.insert(runtime_key); + } + + let mut workspace_skill_roots = + attributed_skill_roots(config, &attribution.skill_owners, &workspace_root)? + .into_values() + .flatten() + .collect::>(); + workspace_skill_roots.sort_by_key(|root| root.precedence); + Ok(PluginConfigProjectionPlan { + workspace_root, + generation_key: generation_key.to_string(), + registrations, + routes, + runtime_agent_keys, + workspace_skill_roots, + tool_runtime_agent_keys, + }) +} + +struct ConfigAttribution { + agent_owners: BTreeMap, + permission_owners: BTreeMap<(String, String), PluginIdentity>, + skill_owners: BTreeMap, +} + +fn config_contribution_sequence( + contributions: &[ConfigContribution], + contributors: &[ConfigContributor], + final_config: &Map, +) -> crate::BitFunResult> { + if contributions.is_empty() { + if contributors.len() == 1 { + return Ok(vec![ConfigContribution { + plugin: contributors[0].plugin.clone(), + outcome: contributors[0].outcome, + config: final_config.clone(), + }]); + } + return Err(crate::BitFunError::Validation( + "unsupported_multiple_config_contributors: plugin host did not provide configContributions" + .to_string(), + )); + } + if contributions.len() != contributors.len() + || contributions + .iter() + .zip(contributors) + .any(|(step, contributor)| { + step.plugin != contributor.plugin || step.outcome != contributor.outcome + }) + { + return Err(crate::BitFunError::Validation( + "Plugin config contribution sequence does not match configContributors".to_string(), + )); + } + if contributions.last().map(|step| &step.config) != Some(final_config) { + return Err(crate::BitFunError::Validation( + "Plugin config contribution sequence does not end at the final config".to_string(), + )); + } + Ok(contributions.to_vec()) +} + +fn attribute_config( + initial_config: &Map, + contributions: &[ConfigContribution], + final_config: &Map, + workspace_root: &Path, +) -> crate::BitFunResult { + let mut previous = initial_config; + let mut agent_owners = BTreeMap::new(); + let mut permission_owners = BTreeMap::new(); + let mut skill_owners = BTreeMap::new(); + let mut previous_skills = skill_paths(initial_config)? + .into_iter() + .map(|path| normalized_skill_path_identity(&path, workspace_root)) + .collect::>>()?; + + for contribution in contributions { + validate_plugin_identity(&contribution.plugin)?; + let before_agents = config_object_field(previous, "agent")?; + let after_agents = config_object_field(&contribution.config, "agent")?; + let agent_ids = before_agents + .keys() + .chain(after_agents.keys()) + .cloned() + .collect::>(); + for agent_id in agent_ids { + let before_agent = before_agents.get(&agent_id); + let after_agent = after_agents.get(&agent_id); + if before_agent != after_agent && after_agent.is_some() { + // The plugin that first turns a native or absent Agent into a + // plugin-managed Agent remains its execution owner. Later + // hooks may refine fields, but do not silently transfer Tool + // and Skill ownership merely by editing a description or + // permission entry. + agent_owners + .entry(agent_id.clone()) + .or_insert_with(|| contribution.plugin.clone()); + } else if after_agent.is_none() { + agent_owners.remove(&agent_id); + } + + let before_permissions = agent_permission_object(before_agent, &agent_id)?; + let after_permissions = agent_permission_object(after_agent, &agent_id)?; + let permission_keys = before_permissions + .keys() + .chain(after_permissions.keys()) + .cloned() + .collect::>(); + for permission in permission_keys { + if before_permissions.get(&permission) == after_permissions.get(&permission) { + continue; + } + let key = (agent_id.clone(), permission.clone()); + if after_permissions.contains_key(&permission) { + permission_owners.insert(key, contribution.plugin.clone()); + } else { + permission_owners.remove(&key); + } + } + } + + let next_skills = skill_paths(&contribution.config)? + .into_iter() + .map(|path| normalized_skill_path_identity(&path, workspace_root)) + .collect::>>()?; + skill_owners.retain(|path, _| next_skills.contains(path)); + for added in next_skills.difference(&previous_skills) { + skill_owners.insert(added.clone(), contribution.plugin.clone()); + } + previous_skills = next_skills; + previous = &contribution.config; + } + if previous != final_config { + return Err(crate::BitFunError::Validation( + "Plugin config attribution did not reach the final config".to_string(), + )); + } + Ok(ConfigAttribution { + agent_owners, + permission_owners, + skill_owners, + }) +} + +fn agent_permission_object( + agent: Option<&Value>, + agent_id: &str, +) -> crate::BitFunResult> { + let Some(agent) = agent else { + return Ok(Map::new()); + }; + let agent = agent.as_object().ok_or_else(|| { + crate::BitFunError::Validation(format!("Plugin agent '{agent_id}' must be an object")) + })?; + match agent.get("permission") { + None | Some(Value::Null) => Ok(Map::new()), + Some(Value::Object(permission)) => Ok(permission.clone()), + Some(_) => Err(crate::BitFunError::Validation(format!( + "Plugin agent '{agent_id}' permission must be an object" + ))), + } +} + +fn native_tool_baseline( + logical_id: &str, + mode: ExternalSubagentMode, + workspace_root: &Path, +) -> Vec { + if let Some(local_agent) = + get_agent_registry().get_local_agent(logical_id, Some(workspace_root)) + { + return local_agent.default_tools(); + } + if mode == ExternalSubagentMode::Subagent { + use crate::agentic::agents::Agent; + ExploreAgent::new().default_tools() + } else { + shared_coding_mode_tools() + } +} + +fn validate_plugin_identity(plugin: &PluginIdentity) -> crate::BitFunResult<()> { + if plugin.spec.trim().is_empty() || plugin.entry.trim().is_empty() { + return Err(crate::BitFunError::Validation( + "Plugin config contributor identity is incomplete".to_string(), + )); + } + Ok(()) +} + +fn validate_agent_id(id: &str) -> crate::BitFunResult<()> { + if id.trim() != id + || id.is_empty() + || id.len() > MAX_AGENT_ID_BYTES + || id.chars().any(char::is_control) + { + return Err(crate::BitFunError::Validation(format!( + "Invalid plugin agent id '{id}'" + ))); + } + Ok(()) +} + +fn parse_mode(value: Option<&Value>, id: &str) -> crate::BitFunResult { + match value.and_then(Value::as_str).unwrap_or("all") { + "primary" => Ok(ExternalSubagentMode::Primary), + "subagent" => Ok(ExternalSubagentMode::Subagent), + "all" => Ok(ExternalSubagentMode::All), + other => Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' has unsupported mode '{other}'" + ))), + } +} + +fn parse_hidden(value: Option<&Value>, id: &str) -> crate::BitFunResult { + match value { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(hidden)) => Ok(*hidden), + Some(_) => Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' hidden must be a boolean" + ))), + } +} + +fn parse_temperature(value: Option<&Value>, id: &str) -> crate::BitFunResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let temperature = value.as_f64().ok_or_else(|| { + crate::BitFunError::Validation(format!("Plugin agent '{id}' temperature must be a number")) + })?; + if !temperature.is_finite() + || !(MIN_AGENT_TEMPERATURE..=MAX_AGENT_TEMPERATURE).contains(&temperature) + { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' temperature must be between {MIN_AGENT_TEMPERATURE} and {MAX_AGENT_TEMPERATURE}" + ))); + } + Ok(Some(temperature)) +} + +fn parse_description( + value: Option<&Value>, + plugin: &PluginIdentity, +) -> crate::BitFunResult { + let description = value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Agent contributed by {}", plugin.label())); + if description.len() > MAX_DESCRIPTION_BYTES { + return Err(crate::BitFunError::Validation( + "Plugin agent description exceeds the size limit".to_string(), + )); + } + Ok(description) +} + +fn parse_prompt(value: Option<&Value>, id: &str) -> crate::BitFunResult { + let prompt = match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(value)) => value.clone(), + Some(_) => { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' prompt must be a string" + ))) + } + }; + if prompt.len() > MAX_PROMPT_BYTES { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' prompt exceeds the size limit" + ))); + } + Ok(prompt) +} + +fn plugin_tool_ids_by_owner( + tools: &[HookFunctionToolRegistration], +) -> crate::BitFunResult>> { + let mut result = BTreeMap::>::new(); + for tool in tools { + let identity = tool.plugin.as_ref().map(PluginIdentity::from); + let identity = identity.ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool identity is missing".to_string()) + })?; + validate_plugin_identity(&identity)?; + let id = tool.id.as_str(); + if id.is_empty() || id.len() > 256 || id.chars().any(char::is_control) { + return Err(crate::BitFunError::Validation( + "Plugin tool id is invalid".to_string(), + )); + } + result.entry(identity).or_default().insert(id.to_string()); + } + Ok(result) +} + +fn parse_permissions( + value: Option<&Value>, + plugin_tools: &BTreeSet, + agent_id: &str, +) -> crate::BitFunResult<(PermissionConstraintLayer, BTreeSet)> { + let Some(value) = value else { + return Ok((PermissionConstraintLayer::default(), BTreeSet::new())); + }; + let permissions = value.as_object().ok_or_else(|| { + crate::BitFunError::Validation(format!( + "Plugin agent '{agent_id}' permission must be an object" + )) + })?; + let known_native = [ + "bash", + "read", + "edit", + "task", + "skill", + "webfetch", + "websearch", + "git", + "external_directory", + ]; + let mut rules = Vec::new(); + let mut denied = BTreeSet::new(); + for (key, value) in permissions { + let effect = match value.as_str() { + Some("allow") => PermissionEffect::Allow, + Some("ask") => PermissionEffect::Ask, + Some("deny") => PermissionEffect::Deny, + _ => { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{agent_id}' permission '{key}' is invalid" + ))) + } + }; + if plugin_tools.contains(key) { + rules.push(PermissionRule::new("custom_tool", key, effect)); + if effect == PermissionEffect::Deny { + denied.insert(key.clone()); + } + } else if known_native.contains(&key.as_str()) { + rules.push(PermissionRule::new(key, "*", effect)); + } else if effect == PermissionEffect::Allow { + log::warn!( + "Ignoring unsupported OpenCode plugin permission allow rule: agent_id={}, permission_action={}", + agent_id, + key + ); + } else { + return Err(crate::BitFunError::Validation(format!("Plugin agent '{agent_id}' permission '{key}' has no compatible action or plugin tool"))); + } + } + Ok((PermissionConstraintLayer::new(rules), denied)) +} + +fn config_object_field( + config: &Map, + field: &str, +) -> crate::BitFunResult> { + match config.get(field) { + None => Ok(Map::new()), + Some(Value::Object(value)) => Ok(value.clone()), + Some(_) => Err(crate::BitFunError::Validation(format!( + "Plugin config '{field}' must be an object" + ))), + } +} + +fn skill_paths(config: &Map) -> crate::BitFunResult> { + let Some(skills) = config.get("skills") else { + return Ok(Vec::new()); + }; + let skills = skills.as_object().ok_or_else(|| { + crate::BitFunError::Validation("Plugin config 'skills' must be an object".to_string()) + })?; + let Some(paths) = skills.get("paths") else { + return Ok(Vec::new()); + }; + let paths = paths.as_array().ok_or_else(|| { + crate::BitFunError::Validation("Plugin config 'skills.paths' must be an array".to_string()) + })?; + paths + .iter() + .map(|path| { + path.as_str().map(PathBuf::from).ok_or_else(|| { + crate::BitFunError::Validation( + "Plugin config 'skills.paths' entries must be strings".to_string(), + ) + }) + }) + .collect() +} + +fn resolve_plugin_skill_path(path: &Path, workspace_root: &Path) -> crate::BitFunResult { + let value = path.to_string_lossy(); + let value = value.trim(); + if value.is_empty() || value.contains('\0') { + return Err(crate::BitFunError::Validation( + "Plugin skill root path is invalid".to_string(), + )); + } + if let Some(relative) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + return dirs::home_dir() + .map(|home| home.join(relative)) + .ok_or_else(|| { + crate::BitFunError::Validation( + "Plugin skill root uses '~/' but the home directory is unavailable".to_string(), + ) + }); + } + let path = PathBuf::from(value); + Ok(if path.is_absolute() { + path + } else { + workspace_root.join(path) + }) +} + +fn normalized_skill_path_identity( + path: &Path, + workspace_root: &Path, +) -> crate::BitFunResult { + let resolved = resolve_plugin_skill_path(path, workspace_root)?; + Ok(dunce::canonicalize(&resolved).unwrap_or(resolved)) +} + +fn attributed_skill_roots( + final_config: &Map, + owners: &BTreeMap, + workspace_root: &Path, +) -> crate::BitFunResult>> { + let mut seen = BTreeSet::new(); + let mut roots = BTreeMap::>::new(); + for path in skill_paths(final_config)? { + let path = resolve_plugin_skill_path(&path, workspace_root)?; + let identity = normalized_skill_path_identity(&path, workspace_root)?; + let Some(owner) = owners.get(&identity) else { + continue; + }; + if !seen.insert(identity) { + continue; + } + if seen.len() > MAX_PLUGIN_SKILL_ROOTS { + return Err(crate::BitFunError::Validation( + "Plugin skill root count exceeds the limit".to_string(), + )); + } + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + log::warn!("Skipping unavailable OpenCode plugin skill root"); + continue; + }; + if bitfun_services_core::bounded_fs::is_symlink_or_reparse(&metadata) || !metadata.is_dir() + { + log::warn!("Skipping invalid OpenCode plugin skill root"); + continue; + } + let Ok(canonical) = dunce::canonicalize(&path) else { + log::warn!("Skipping OpenCode plugin skill root that cannot be canonicalized"); + continue; + }; + let owned_roots = roots.entry(owner.clone()).or_default(); + owned_roots.push(PluginSkillRootContribution { + path: canonical, + precedence: seen.len() - 1, + }); + } + Ok(roots) +} + +pub(crate) fn active_generation_key(workspace_root: &Path) -> Option { + let root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + skill_generations() + .read() + .ok()? + .get(&root) + .map(|generation| generation.generation_key.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn plugin() -> Value { + json!({ + "id": "deveco-harness", + "spec": "D:/code/deveco_harness", + "entry": "D:/code/deveco_harness/dist/index.js", + "index": 0 + }) + } + + fn projection_identity(value: Value) -> PluginIdentity { + let typed: HookFunctionPluginIdentity = + serde_json::from_value(value).expect("typed plugin identity"); + PluginIdentity::from(&typed) + } + + fn open_result() -> Value { + let plugin = plugin(); + let config = json!({ + "agent": { + "build": { + "mode": "primary", + "temperature": 0.7, + "description": "Build projects", + "prompt": "Build prompt", + "permission": {"build_project": "allow", "plan_write": "deny"} + }, + "explore": { + "mode": "subagent", + "hidden": true, + "description": "Explore projects", + "prompt": "Explore prompt", + "permission": {"bash": "deny"} + } + } + }); + json!({ + "configContributors": [{"plugin": plugin.clone(), "outcome": "applied"}], + "config": config.clone(), + "configContributions": [{"plugin": plugin.clone(), "outcome": "applied", "config": config}], + "tools": [ + {"id": "build_project", "plugin": plugin.clone()}, + {"id": "plan_write", "plugin": plugin} + ] + }) + } + + fn registration_batch(result: &Value) -> HookFunctionRegistrationBatch { + let tools = result + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|tool| HookFunctionToolRegistration { + registration_id: format!( + "registration-{}", + tool.get("id").and_then(Value::as_str).unwrap_or_default() + ), + id: tool + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + plugin: tool + .get("plugin") + .cloned() + .map(serde_json::from_value) + .transpose() + .expect("typed tool owner"), + description: String::new(), + parameters: json!({"type": "object"}), + }) + .collect(); + HookFunctionRegistrationBatch { + generation: bitfun_runtime_ports::HookFunctionGeneration { + instance_id: "projection-test".to_string(), + generation_key: "projection-generation".to_string(), + revision: "projection-revision".to_string(), + }, + config: result + .get("config") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(), + config_contributors: serde_json::from_value( + result + .get("configContributors") + .cloned() + .unwrap_or_else(|| json!([])), + ) + .expect("typed config contributors"), + config_contributions: serde_json::from_value( + result + .get("configContributions") + .cloned() + .unwrap_or_else(|| json!([])), + ) + .expect("typed config contributions"), + diagnostics: Vec::new(), + hooks: Vec::new(), + tools, + } + } + + #[test] + fn generation_scoped_release_never_withdraws_a_replacement() { + let workspace = tempfile::tempdir().expect("workspace"); + PluginConfigProjectionPlan::empty(workspace.path(), "generation-a").commit(); + + assert!(!release_workspace_generation( + workspace.path(), + "generation-b" + )); + assert_eq!( + active_generation_key(workspace.path()).as_deref(), + Some("generation-a") + ); + assert!(release_workspace_generation( + workspace.path(), + "generation-a" + )); + assert_eq!(active_generation_key(workspace.path()), None); + } + + #[test] + fn publishes_plugin_skill_roots_to_all_workspace_agents() { + let workspace = tempfile::tempdir().expect("workspace"); + let skill_root = tempfile::tempdir().expect("plugin skill root"); + let plugin = plugin(); + let config = json!({"skills": {"paths": [skill_root.path()]}}); + let result = json!({ + "configContributors": [{"plugin": plugin.clone(), "outcome": "applied"}], + "config": config.clone(), + "configContributions": [{ + "plugin": plugin, + "outcome": "applied", + "config": config + }], + "tools": [] + }); + + let plan = prepare( + workspace.path(), + "skill-only-generation", + &Map::new(), + ®istration_batch(&result), + ) + .expect("skill-only plugin projection"); + assert!(plan.registrations.is_empty()); + plan.commit(); + + for agent in [Some("build"), Some("external-agent"), None] { + let roots = skill_roots_for_agent(Some(workspace.path()), agent); + assert_eq!(roots.len(), 1); + assert_eq!( + roots[0].path, + dunce::canonicalize(skill_root.path()).unwrap() + ); + } + release_workspace(workspace.path()); + } + + #[test] + fn resolves_plugin_skill_roots_relative_to_the_workspace() { + let workspace = tempfile::tempdir().expect("workspace"); + let skill_root = workspace.path().join("skills"); + std::fs::create_dir(&skill_root).expect("plugin skill root"); + let plugin = plugin(); + let config = json!({"skills": {"paths": ["./skills"]}}); + let result = json!({ + "configContributors": [{"plugin": plugin.clone(), "outcome": "applied"}], + "config": config.clone(), + "configContributions": [{ + "plugin": plugin, + "outcome": "applied", + "config": config + }], + "tools": [] + }); + + let plan = prepare( + workspace.path(), + "relative-skill-generation", + &Map::new(), + ®istration_batch(&result), + ) + .expect("relative plugin skill root"); + + assert_eq!(plan.workspace_skill_roots.len(), 1); + assert_eq!( + plan.workspace_skill_roots[0].path, + dunce::canonicalize(skill_root).unwrap() + ); + } + + #[test] + fn resolves_home_relative_plugin_skill_roots() { + let workspace = tempfile::tempdir().expect("workspace"); + let home = dirs::home_dir().expect("home directory"); + + assert_eq!( + resolve_plugin_skill_path(Path::new("~/skills"), workspace.path()).unwrap(), + home.join("skills") + ); + } + + #[test] + fn unavailable_plugin_skill_roots_do_not_cancel_the_generation() { + let workspace = tempfile::tempdir().expect("workspace"); + let plugin = plugin(); + let config = json!({"skills": {"paths": ["./not-created"]}}); + let result = json!({ + "configContributors": [{"plugin": plugin.clone(), "outcome": "applied"}], + "config": config.clone(), + "configContributions": [{ + "plugin": plugin, + "outcome": "applied", + "config": config + }], + "tools": [] + }); + + let plan = prepare( + workspace.path(), + "unavailable-skill-generation", + &Map::new(), + ®istration_batch(&result), + ) + .expect("unavailable skill root is isolated"); + + assert!(plan.workspace_skill_roots.is_empty()); + } + + #[test] + fn maps_target_agent_fields_and_plugin_tool_permissions() { + let plan = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&open_result()), + ) + .expect("projection"); + + assert_eq!(plan.registrations.len(), 2); + let build = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "build") + .unwrap(); + assert_eq!(build.mode, ExternalSubagentMode::Primary); + assert!(!build.hidden); + assert_eq!(build.agent.model_temperature_override(), Some(0.7)); + assert_eq!(build.agent.description(), "Build projects"); + assert!(build + .agent + .default_tools() + .contains(&"build_project".to_string())); + assert!(!build + .agent + .default_tools() + .contains(&"plan_write".to_string())); + assert!(build + .agent + .permission_constraints() + .rules() + .iter() + .any(|rule| { + rule.action == "custom_tool" + && rule.resource == "plan_write" + && rule.effect == PermissionEffect::Deny + })); + + let explore = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "explore") + .unwrap(); + assert_eq!(explore.mode, ExternalSubagentMode::Subagent); + assert!(explore.hidden); + assert_eq!(explore.agent.model_temperature_override(), None); + assert!(explore + .agent + .permission_constraints() + .rules() + .iter() + .any(|rule| { + rule.action == "bash" + && rule.resource == "*" + && rule.effect == PermissionEffect::Deny + })); + assert_eq!(plan.runtime_agent_keys.len(), 2); + assert!(plan + .runtime_agent_keys + .iter() + .all(|key| is_plugin_agent_runtime_key(key))); + } + + #[test] + fn displaced_local_baseline_is_case_insensitive() { + use crate::agentic::agents::{Agent, CoworkMode}; + + assert_eq!( + native_tool_baseline( + "cowork", + ExternalSubagentMode::Primary, + Path::new("C:/workspace") + ), + CoworkMode::new().default_tools() + ); + } + + #[test] + fn projects_multiple_config_contributors_and_isolates_agent_tools() { + let mut result = open_result(); + let second = json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + }); + let mut second_config = result["config"].as_object().unwrap().clone(); + second_config["agent"]["build"]["description"] = json!("Second build"); + second_config["agent"]["build"]["permission"]["second_tool"] = json!("allow"); + second_config["agent"]["plan"] = json!({ + "mode": "subagent", + "description": "Plan", + "prompt": "Plan prompt", + "permission": {"second_tool": "allow"} + }); + result["configContributors"] = json!([ + {"plugin": plugin(), "outcome": "applied"}, + {"plugin": second.clone(), "outcome":"applied"} + ]); + result["config"] = Value::Object(second_config.clone()); + result["configContributions"] = json!([ + {"plugin": plugin(), "outcome": "applied", "config": open_result()["config"].clone()}, + {"plugin": second.clone(), "outcome":"applied", "config": second_config} + ]); + result["tools"] + .as_array_mut() + .unwrap() + .push(json!({"id": "second_tool", "plugin": second})); + result["tools"].as_array_mut().unwrap().push( + json!({"id": "second_tool_ungranted", "plugin": json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + })}), + ); + + let plan = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&result), + ) + .expect("multiple config contributors should project"); + assert_eq!(plan.registrations.len(), 3); + assert!(plan + .registrations + .iter() + .find(|registration| registration.logical_id == "build") + .unwrap() + .agent + .description() + .contains("Second build")); + let build_tools = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "build") + .unwrap() + .agent + .default_tools(); + let plan_tools = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "plan") + .unwrap() + .agent + .default_tools(); + assert!(build_tools.contains(&"build_project".to_string())); + assert!(build_tools.contains(&"second_tool".to_string())); + assert!(!build_tools.contains(&"second_tool_ungranted".to_string())); + assert!(plan_tools.contains(&"second_tool".to_string())); + assert!(plan_tools.contains(&"second_tool_ungranted".to_string())); + } + + #[test] + fn supports_legacy_single_contributor_without_contribution_snapshots() { + let mut result = open_result(); + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + + let plan = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&result), + ) + .expect("single contributor legacy projection"); + + assert_eq!(plan.registrations.len(), 2); + } + + #[test] + fn rejects_legacy_multiple_contributors_without_contribution_snapshots() { + let mut result = open_result(); + result["configContributors"] = json!([ + {"plugin": plugin(), "outcome": "applied"}, + {"plugin": { + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + }, "outcome": "applied"} + ]); + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&result), + ) + .err() + .expect("multiple contributors require contribution snapshots"); + + assert!(error + .to_string() + .contains("unsupported_multiple_config_contributors")); + } + + #[test] + fn rejects_inconsistent_config_contribution_sequences() { + let mut result = open_result(); + result["configContributions"][0]["outcome"] = json!("failed"); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&result), + ) + .err() + .expect("contributor metadata must align"); + assert!(error + .to_string() + .contains("does not match configContributors")); + + let mut result = open_result(); + result["configContributions"][0]["config"] = json!({"agent": {}}); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&result), + ) + .err() + .expect("last contribution must equal final config"); + assert!(error + .to_string() + .contains("does not end at the final config")); + } + + #[test] + fn rejects_malformed_agent_and_skill_shapes() { + let mut result = open_result(); + result["config"]["agent"] = json!([]); + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&result), + ) + .err() + .expect("agent must be an object"); + assert!(error + .to_string() + .contains("config 'agent' must be an object")); + + for malformed in [json!({"paths": "not-an-array"}), json!({"paths": [42]})] { + let mut result = open_result(); + result["config"]["skills"] = malformed; + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + ®istration_batch(&result), + ) + .err() + .expect("malformed skill paths must fail"); + assert!(error.to_string().contains("skills.paths")); + } + } + + #[test] + fn canonical_skill_identity_does_not_republish_an_initial_root() { + let directory = tempfile::tempdir().expect("temp directory"); + let canonical = dunce::canonicalize(directory.path()).expect("canonical path"); + let aliased = canonical.join("."); + let initial = json!({"skills": {"paths": [aliased]}}) + .as_object() + .unwrap() + .clone(); + let final_config = json!({"skills": {"paths": [canonical]}}) + .as_object() + .unwrap() + .clone(); + + let contributor = ConfigContribution { + plugin: projection_identity(plugin()), + outcome: ContributorOutcome::Applied, + config: final_config.clone(), + }; + let attribution = + attribute_config(&initial, &[contributor], &final_config, directory.path()) + .expect("skill attribution"); + assert!(attribution.skill_owners.is_empty()); + assert!( + attributed_skill_roots(&final_config, &attribution.skill_owners, directory.path()) + .expect("skill roots") + .is_empty() + ); + } + + #[test] + fn attributes_skill_additions_across_reordering_and_removal() { + let base = tempfile::tempdir().expect("base skill root"); + let first = tempfile::tempdir().expect("first plugin skill root"); + let second = tempfile::tempdir().expect("second plugin skill root"); + let plugin_a = projection_identity(plugin()); + let plugin_b = projection_identity(json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + })); + let initial = json!({"skills": {"paths": [base.path()]}}) + .as_object() + .unwrap() + .clone(); + let after_a = json!({"skills": {"paths": [base.path(), first.path()]}}) + .as_object() + .unwrap() + .clone(); + let final_config = json!({"skills": {"paths": [first.path(), base.path(), second.path()]}}) + .as_object() + .unwrap() + .clone(); + let contributions = vec![ + ConfigContribution { + plugin: plugin_a.clone(), + outcome: ContributorOutcome::Applied, + config: after_a, + }, + ConfigContribution { + plugin: plugin_b.clone(), + outcome: ContributorOutcome::Applied, + config: final_config.clone(), + }, + ]; + + let attribution = attribute_config(&initial, &contributions, &final_config, base.path()) + .expect("skill attribution"); + assert_eq!( + attribution + .skill_owners + .get(&normalized_skill_path_identity(first.path(), base.path()).unwrap()), + Some(&plugin_a) + ); + assert_eq!( + attribution + .skill_owners + .get(&normalized_skill_path_identity(second.path(), base.path()).unwrap()), + Some(&plugin_b) + ); + assert!(!attribution + .skill_owners + .contains_key(&normalized_skill_path_identity(base.path(), base.path()).unwrap())); + + let removed_config = json!({"skills": {"paths": [base.path(), second.path()]}}) + .as_object() + .unwrap() + .clone(); + let mut removal_sequence = contributions; + removal_sequence.push(ConfigContribution { + plugin: plugin_b, + outcome: ContributorOutcome::Applied, + config: removed_config.clone(), + }); + let removed = attribute_config(&initial, &removal_sequence, &removed_config, base.path()) + .expect("skill removal attribution"); + assert!(!removed + .skill_owners + .contains_key(&normalized_skill_path_identity(first.path(), base.path()).unwrap())); + } + + #[test] + fn reattributes_deleted_and_recreated_agents_and_permission_fields() { + let plugin_a = projection_identity(plugin()); + let plugin_b = projection_identity(json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + })); + let initial = json!({"agent": {"build": {"prompt": "native"}}}) + .as_object() + .unwrap() + .clone(); + let after_a = json!({"agent": {"build": { + "prompt": "plugin-a", + "permission": {"build_project": "allow"} + }}}) + .as_object() + .unwrap() + .clone(); + let after_delete = json!({"agent": {}}).as_object().unwrap().clone(); + let final_config = json!({"agent": {"build": { + "prompt": "plugin-b", + "permission": {"second_tool": "ask"} + }}}) + .as_object() + .unwrap() + .clone(); + let contributions = vec![ + ConfigContribution { + plugin: plugin_a, + outcome: ContributorOutcome::Applied, + config: after_a, + }, + ConfigContribution { + plugin: plugin_b.clone(), + outcome: ContributorOutcome::Applied, + config: after_delete, + }, + ConfigContribution { + plugin: plugin_b.clone(), + outcome: ContributorOutcome::Applied, + config: final_config.clone(), + }, + ]; + + let attribution = attribute_config( + &initial, + &contributions, + &final_config, + Path::new("C:/workspace"), + ) + .expect("agent attribution"); + assert_eq!(attribution.agent_owners.get("build"), Some(&plugin_b)); + assert_eq!( + attribution + .permission_owners + .get(&("build".to_string(), "second_tool".to_string())), + Some(&plugin_b) + ); + assert!(!attribution + .permission_owners + .contains_key(&("build".to_string(), "build_project".to_string()))); + } + + #[test] + fn unknown_allow_is_non_expanding_but_unknown_restrictions_fail_closed() { + let plugin_tools = BTreeSet::new(); + let permissions = json!({"future_action": "allow"}); + let (constraints, denied) = + parse_permissions(Some(&permissions), &plugin_tools, "build").expect("allow"); + assert!(constraints.rules().is_empty()); + assert!(denied.is_empty()); + + for effect in ["ask", "deny"] { + let permissions = json!({"future_action": effect}); + let error = parse_permissions(Some(&permissions), &plugin_tools, "build") + .expect_err("unknown restriction cannot be enforced"); + assert!(error.to_string().contains("has no compatible action")); + } + } + + #[test] + fn parses_hidden_and_temperature_with_safe_defaults_and_bounds() { + assert!(!parse_hidden(None, "agent").expect("hidden defaults to false")); + assert!(parse_hidden(Some(&json!(true)), "agent").expect("boolean hidden")); + assert!(!parse_hidden(Some(&json!(null)), "agent").expect("null hidden default")); + assert!(parse_hidden(Some(&json!("true")), "agent") + .expect_err("non-boolean hidden must fail") + .to_string() + .contains("hidden must be a boolean")); + + assert_eq!(parse_temperature(None, "agent").unwrap(), None); + assert_eq!( + parse_temperature(Some(&json!(null)), "agent").unwrap(), + None + ); + assert_eq!( + parse_temperature(Some(&json!(0.2)), "agent").unwrap(), + Some(0.2) + ); + assert_eq!( + parse_temperature(Some(&json!(2)), "agent").unwrap(), + Some(2.0) + ); + for value in [json!(-0.1), json!(2.1), json!("0.2")] { + assert!(parse_temperature(Some(&value), "agent").is_err()); + } + } +} diff --git a/src/crates/assembly/core/src/plugin_hook_bridge.rs b/src/crates/assembly/core/src/plugin_hook_bridge.rs new file mode 100644 index 0000000000..1a0e7a19a1 --- /dev/null +++ b/src/crates/assembly/core/src/plugin_hook_bridge.rs @@ -0,0 +1,321 @@ +//! Bridge the provider-neutral native hook executor to the OpenCode RPC host. + +use bitfun_agent_runtime::native_hooks::{ + AgentHookMatcher, PluginHookCall, PluginHookExecutor, PluginHookResult, RuntimeHookCommitToken, + RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistration, + RuntimeHookRegistry, RuntimeHookSource, +}; +use bitfun_runtime_ports::{ + HookFunctionAfterOutput, HookFunctionAfterRequest, HookFunctionBeforeRequest, + HookFunctionGeneration, HookFunctionRuntime, +}; +use serde_json::Value; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone)] +pub(crate) struct PluginHostHookExecutor { + runtime: Arc, + deadline: Duration, +} + +impl PluginHostHookExecutor { + pub(crate) fn new(runtime: Arc) -> Self { + Self { + runtime, + deadline: Duration::from_secs(30), + } + } +} + +#[async_trait::async_trait] +impl PluginHookExecutor for PluginHostHookExecutor { + async fn execute(&self, call: PluginHookCall) -> Result { + let generation = HookFunctionGeneration { + instance_id: call.instance_id.clone(), + generation_key: call.generation_key.clone(), + revision: call.revision.clone(), + }; + let input = call.input.clone(); + let output = match call.hook_name.as_str() { + "tool.execute.before" => { + let result = self + .runtime + .transform_tool_before( + HookFunctionBeforeRequest { + generation, + tool_name: string_field(&call.input, "tool"), + session_id: string_field(&call.input, "sessionID"), + call_id: string_field(&call.input, "callID"), + args: call.output.get("args").cloned().ok_or_else(|| { + "tool.execute.before input is missing args".to_string() + })?, + }, + self.deadline, + ) + .await + .map_err(|error| error.to_string())?; + serde_json::json!({"args": result.args}) + } + "tool.execute.after" => { + let metadata = call + .output + .get("metadata") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| { + "tool.execute.after output metadata must be an object".to_string() + })?; + let result = self + .runtime + .transform_tool_after( + HookFunctionAfterRequest { + generation, + tool_name: string_field(&call.input, "tool"), + session_id: string_field(&call.input, "sessionID"), + call_id: string_field(&call.input, "callID"), + args: call.input.get("args").cloned().unwrap_or(Value::Null), + output: HookFunctionAfterOutput { + title: string_field(&call.output, "title"), + output: string_field(&call.output, "output"), + metadata, + }, + }, + self.deadline, + ) + .await + .map_err(|error| error.to_string())?; + serde_json::to_value(result).map_err(|error| error.to_string())? + } + other => return Err(format!("unsupported operational plugin hook: {other}")), + }; + Ok(PluginHookResult { + instance_id: call.instance_id, + generation_key: call.generation_key, + revision: call.revision, + hook_name: call.hook_name, + input, + output, + }) + } +} + +fn string_field(value: &Value, field: &str) -> String { + value + .get(field) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +#[cfg(test)] +pub(crate) fn register_plugin_hooks( + registry: &RuntimeHookRegistry, + workspace_scope: &str, + runtime: Arc, + instance_id: &str, + generation_key: &str, + revision: &str, + hook_names: &[String], +) -> Result, String> { + register_plugin_hooks_with_runtime( + registry, + workspace_scope, + runtime, + instance_id, + generation_key, + revision, + hook_names, + ) +} + +pub(crate) fn register_plugin_hooks_with_runtime( + registry: &RuntimeHookRegistry, + workspace_scope: &str, + runtime: Arc, + instance_id: &str, + generation_key: &str, + revision: &str, + hook_names: &[String], +) -> Result, String> { + log::debug!( + "Plugin hook registration preparing: workspace={}, instance_id={}, hook_count={}", + workspace_scope, + instance_id, + hook_names.len() + ); + let executor: Arc = Arc::new(PluginHostHookExecutor::new(runtime)); + let entries = hook_names + .iter() + .map(|hook_name| { + let id = format!( + "opencode:{workspace_scope}:{instance_id}:{generation_key}:{revision}:{hook_name}" + ); + RuntimeHookRegistration::plugin( + RuntimeHookPlan::new( + id, + RuntimeHookKind::PluginHook(hook_name.clone()), + RuntimeHookSource::Plugin, + ) + // The adapter owns a 30s RPC deadline. Keep the registry's + // outer guard slightly longer so it observes that terminal + // error instead of dropping an in-flight Host request. + .with_timeout_millis(31_000) + .with_error_policy(RuntimeHookErrorPolicy::DenyTool), + hook_name, + instance_id, + generation_key, + revision, + executor.clone(), + AgentHookMatcher::Any, + ) + .with_workspace_scope(workspace_scope) + }) + .collect::>(); + if entries.is_empty() { + log::debug!( + "Plugin hook registration prepared with no dispatch hooks: workspace={}, instance_id={}", + workspace_scope, + instance_id + ); + return Ok(None); + } + let token = match registry.register_plugin_batch(entries) { + Ok(token) => token, + Err(error) => { + log::error!( + "Plugin hook registration failed: workspace={}, instance_id={}, hook_count={}, error={}", + workspace_scope, + instance_id, + hook_names.len(), + error + ); + return Err(error.to_string()); + } + }; + log::info!( + "Plugin hook registration prepared in Rust registry: workspace={}, instance_id={}, target_id={}, generation_key={}, revision={}, hook_count={}", + workspace_scope, + instance_id, + token.target_id(), + token.generation_key(), + token.revision(), + hook_names.len() + ); + Ok(Some(token)) +} + +pub(crate) fn commit_plugin_generation( + registry: &RuntimeHookRegistry, + workspace_scope: &str, + token: Option<&RuntimeHookCommitToken>, +) { + registry.activate_plugin_batch(workspace_scope, token); +} + +pub(crate) fn unregister_plugin_hooks( + registry: &RuntimeHookRegistry, + workspace_scope: &str, + token: RuntimeHookCommitToken, +) { + registry.rollback_plugin_batch(&token); + let _ = workspace_scope; +} + +pub(crate) fn withdraw_plugin_workspace(registry: &RuntimeHookRegistry, workspace_scope: &str) { + registry.withdraw_plugin_workspace(workspace_scope); +} + +pub(crate) fn hook_names( + batch: &bitfun_runtime_ports::HookFunctionRegistrationBatch, +) -> Vec { + batch + .hooks + .iter() + .map(|hook| match hook { + bitfun_runtime_ports::HookFunctionHookKind::ToolExecuteBefore => "tool.execute.before", + bitfun_runtime_ports::HookFunctionHookKind::ToolExecuteAfter => "tool.execute.after", + }) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{commit_plugin_generation, register_plugin_hooks}; + use bitfun_agent_runtime::native_hooks::{ + RuntimeHookActivation, RuntimeHookRegistry, RuntimeHookSource, + }; + use bitfun_opencode_plugin_host::JsonRpcPeer; + use tokio::net::{TcpListener, TcpStream}; + + async fn client() -> bitfun_opencode_plugin_host::PluginHostClient { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let host = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (backend, _) = listener.accept().await.unwrap(); + let _host = host.await.unwrap(); + JsonRpcPeer::start_with_capabilities( + backend, + 1, + 1024 * 1024, + bitfun_opencode_plugin_host::PluginHostCapabilities::all_supported(), + ) + .client() + } + + #[tokio::test] + async fn empty_hook_set_is_ready_without_a_commit_token() { + let registry = RuntimeHookRegistry::default(); + let token = register_plugin_hooks( + ®istry, + "C:/workspace", + bitfun_opencode_plugin_host::hook_function_runtime(client().await), + "instance-a", + "generation-a", + "revision-a", + &[], + ) + .unwrap(); + + assert!(token.is_none()); + commit_plugin_generation(®istry, "C:/workspace", token.as_ref()); + assert_eq!( + registry + .source_activation_for_workspace(RuntimeHookSource::Plugin, Some("C:/workspace")), + RuntimeHookActivation::Ready + ); + } + + #[tokio::test] + async fn duplicate_hook_registration_preserves_active_generation() { + let registry = RuntimeHookRegistry::default(); + let hooks = vec!["tool.execute.before".to_string()]; + let first = register_plugin_hooks( + ®istry, + "C:/workspace", + bitfun_opencode_plugin_host::hook_function_runtime(client().await), + "instance-a", + "generation-a", + "revision-a", + &hooks, + ) + .unwrap(); + commit_plugin_generation(®istry, "C:/workspace", first.as_ref()); + assert!(register_plugin_hooks( + ®istry, + "C:/workspace", + bitfun_opencode_plugin_host::hook_function_runtime(client().await), + "instance-a", + "generation-a", + "revision-a", + &hooks, + ) + .is_err()); + assert_eq!( + registry + .source_activation_for_workspace(RuntimeHookSource::Plugin, Some("C:/workspace")), + RuntimeHookActivation::Ready + ); + } +} diff --git a/src/crates/assembly/core/src/plugin_host.rs b/src/crates/assembly/core/src/plugin_host.rs index 58fd638f16..d03b27b8b1 100644 --- a/src/crates/assembly/core/src/plugin_host.rs +++ b/src/crates/assembly/core/src/plugin_host.rs @@ -1,12 +1,20 @@ +use bitfun_agent_runtime::native_hooks::RuntimeHookCommitToken; use bitfun_opencode_plugin_host::{ + BackendDiagnostic, BackendDiagnosticError, BackendDiagnosticEvent, BackendDiagnosticSeverity, PluginDeclaration, PluginHost, PluginHostConfig, PluginHostShutdownPolicy, - PluginHostShutdownReport, PluginInstanceOpenRequest, PluginPrepareRequest, + PluginHostShutdownReport, PluginPrepareRequest, GENERATION_FENCING_V1, }; -use serde_json::{Map, Value}; +use bitfun_runtime_ports::{ + HookFunctionDisposeRequest, HookFunctionGeneration, HookFunctionPluginDeclaration, + HookFunctionRegistrationBatch, HookFunctionRegistrationSink, HookFunctionRuntime, + HookFunctionStartRequest, PortError, PortErrorKind, PortResult, +}; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; // Product-assembly bridge for the managed OpenCode Plugin Host. // // `PluginHost` itself remains the adapter-owned process/IPC resource. Core @@ -17,18 +25,110 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use terminal_core::{CloseSessionRequest, TerminalApi}; use tokio::sync::{Mutex, Notify, OnceCell}; +#[derive(Default)] +struct CapturedPluginGeneration { + batch: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl HookFunctionRegistrationSink for CapturedPluginGeneration { + async fn publish_generation(&self, batch: HookFunctionRegistrationBatch) -> PortResult<()> { + let mut slot = self.batch.lock().expect("plugin generation lock poisoned"); + if slot.is_some() { + return Err(PortError::new( + PortErrorKind::Backend, + "plugin runtime published more than one registration batch for a generation", + )); + } + *slot = Some(batch); + Ok(()) + } +} + +impl CapturedPluginGeneration { + fn take(&self) -> crate::BitFunResult { + self.batch + .lock() + .expect("plugin generation lock poisoned") + .take() + .ok_or_else(|| { + crate::BitFunError::ProcessError( + "Plugin runtime started without publishing its registration batch".to_string(), + ) + }) + } +} + const BUN_HOST_ENTRY_ENV: &str = "BITFUN_OPENCODE_BUN_HOST_ENTRY"; const BUN_COMMAND_ENV: &str = "BITFUN_BUN_COMMAND"; static PLUGIN_HOST: OnceCell>> = OnceCell::const_new(); +static PLUGIN_HOST_LIFECYCLE_LOCK: OnceCell> = OnceCell::const_new(); static PLUGIN_HOST_SHUTDOWN_REPORT: OnceCell>> = OnceCell::const_new(); static PLUGIN_HOST_SHUTDOWN_NOTIFY: OnceCell = OnceCell::const_new(); +static PLUGIN_HOST_ACTIVE_ENSURE_NOTIFY: OnceCell = OnceCell::const_new(); static PLUGIN_HOST_SHUTDOWN_STARTED: AtomicBool = AtomicBool::new(false); static PLUGIN_HOST_SHUTDOWN_COMPLETE: AtomicBool = AtomicBool::new(false); +static PLUGIN_HOST_ACTIVE_ENSURES: AtomicU64 = AtomicU64::new(0); static PLUGIN_HOST_INSTANCES: OnceCell>> = OnceCell::const_new(); +static PLUGIN_HOST_ENSURE_LOCKS: OnceCell>>>> = + OnceCell::const_new(); static PLUGIN_HOST_PTY_OWNERS: OnceCell>> = OnceCell::const_new(); +static PLUGIN_ACTIVATION_FAILURES: std::sync::OnceLock>> = + std::sync::OnceLock::new(); static NEXT_INSTANCE_SEQUENCE: AtomicU64 = AtomicU64::new(1); +const MAX_PLUGIN_HOST_DIAGNOSTICS: usize = 100; + +struct PluginHostEnsureLease; + +impl Drop for PluginHostEnsureLease { + fn drop(&mut self) { + if PLUGIN_HOST_ACTIVE_ENSURES.fetch_sub(1, Ordering::AcqRel) == 1 { + if let Some(notify) = PLUGIN_HOST_ACTIVE_ENSURE_NOTIFY.get() { + notify.notify_waiters(); + } + } + } +} + +async fn acquire_plugin_host_ensure_lease() -> crate::BitFunResult { + let lifecycle_lock = PLUGIN_HOST_LIFECYCLE_LOCK + .get_or_init(|| async { Mutex::new(()) }) + .await; + let _guard = lifecycle_lock.lock().await; + if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { + return Err(crate::BitFunError::ProcessError( + "Plugin host is shutting down".to_string(), + )); + } + PLUGIN_HOST_ACTIVE_ENSURES.fetch_add(1, Ordering::AcqRel); + Ok(PluginHostEnsureLease) +} + +async fn wait_for_plugin_host_ensure_leases() { + let notify = PLUGIN_HOST_ACTIVE_ENSURE_NOTIFY + .get_or_init(|| async { Notify::new() }) + .await; + loop { + let notified = notify.notified(); + if PLUGIN_HOST_ACTIVE_ENSURES.load(Ordering::Acquire) == 0 { + return; + } + notified.await; + } +} + +async fn plugin_host_workspace_lock(scope: &str) -> Arc> { + let locks = PLUGIN_HOST_ENSURE_LOCKS + .get_or_init(|| async { Mutex::new(HashMap::new()) }) + .await; + let mut locks = locks.lock().await; + locks + .entry(scope.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} #[derive(Debug, Clone)] pub(crate) struct PluginHostInstance { @@ -38,14 +138,31 @@ pub(crate) struct PluginHostInstance { pub(crate) project_id: String, pub(crate) created_at_ms: i64, pub(crate) instance_id: String, - pub(crate) open_result: Value, + pub(crate) host_generation: u64, + pub(crate) generation_key: String, + pub(crate) revision: String, + pub(crate) registration_batch: Option, pub(crate) ready: bool, + pub(crate) hook_commit_token: Option, + pub(crate) transformed_config_health_snapshot: Option, + pub(crate) diagnostic_health_snapshot: Vec, + pub(crate) tool_names: Vec, + pub(crate) agent_runtime_keys: Vec, + pub(crate) retirement_scheduled: bool, } impl PluginHostInstance { pub(crate) fn is_ready(&self) -> bool { self.ready } + + fn generation(&self) -> HookFunctionGeneration { + HookFunctionGeneration { + instance_id: self.instance_id.clone(), + generation_key: self.generation_key.clone(), + revision: self.revision.clone(), + } + } } #[derive(Debug, Clone, Copy)] @@ -82,6 +199,14 @@ pub enum PluginHostLaunchPolicy { Disabled, } +pub async fn configured_plugins_present() -> crate::BitFunResult { + use crate::service::config::{get_global_config_service, GlobalConfig}; + + let config_service = get_global_config_service().await?; + let config: GlobalConfig = config_service.get_config(None).await?; + Ok(config.has_configured_plugins()) +} + pub async fn initialize_configured_plugin_host( launch_policy: PluginHostLaunchPolicy, ) -> crate::BitFunResult { @@ -99,9 +224,27 @@ pub async fn initialize_configured_plugin_host_with_log_file( } let config_service = get_global_config_service().await?; let config: GlobalConfig = config_service.get_config(None).await?; + let startup = + initialize_configured_plugin_host_from_config(launch_policy, log_file, &config).await?; + clear_configured_plugin_activation_failure(None); + Ok(startup) +} + +async fn initialize_configured_plugin_host_from_config( + launch_policy: PluginHostLaunchPolicy, + log_file: Option, + config: &crate::service::config::GlobalConfig, +) -> crate::BitFunResult { + if launch_policy == PluginHostLaunchPolicy::Disabled { + return Ok(PluginHostStartup::Disabled); + } if !config.has_configured_plugins() { return Ok(PluginHostStartup::Disabled); } + let lifecycle_lock = PLUGIN_HOST_LIFECYCLE_LOCK + .get_or_init(|| async { Mutex::new(()) }) + .await; + let _lifecycle_guard = lifecycle_lock.lock().await; if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { return Err(crate::BitFunError::ProcessError( "Plugin host is shutting down".to_string(), @@ -110,14 +253,28 @@ pub async fn initialize_configured_plugin_host_with_log_file( let launch_spec = PluginHostLaunchSpec::bun(); let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; - let mut host_state = host_state.lock().await; - if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { - return Err(crate::BitFunError::ProcessError( - "Plugin host is shutting down".to_string(), - )); - } - if host_state.is_some() { - return Ok(PluginHostStartup::AlreadyStarted); + let stale_host = { + let mut host_state = host_state.lock().await; + if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { + return Err(crate::BitFunError::ProcessError( + "Plugin host is shutting down".to_string(), + )); + } + if let Some(host) = host_state.as_mut() { + if host + .is_connected() + .map_err(|error| crate::BitFunError::ProcessError(error.to_string()))? + { + return Ok(PluginHostStartup::AlreadyStarted); + } + log::warn!("Configured plugin host slot contained a disconnected host; retiring it before restart"); + host_state.take() + } else { + None + } + }; + if let Some(stale) = stale_host { + let _ = stale.shutdown(PluginHostShutdownPolicy::default()).await; } let path_manager = crate::infrastructure::try_get_path_manager_arc()?; let log_file = log_file.unwrap_or_else(|| path_manager.logs_dir().join("plugin-host.log")); @@ -140,75 +297,23 @@ pub async fn initialize_configured_plugin_host_with_log_file( log_level: config.app.logging.level.trim().to_lowercase(), }) .await - .map_err(|error| match error { - bitfun_opencode_plugin_host::PluginHostError::RuntimeNotFound(command) => { - crate::BitFunError::ProcessError(format!( - "{} executable was not found at {}. Install Bun or set {} to a valid Bun executable.", - launch_spec.runtime_name, - command.display(), - BUN_COMMAND_ENV - )) - } - error => crate::BitFunError::ProcessError(format!( + .map_err(|error| { + crate::BitFunError::ProcessError(format!( "Failed to initialize {} plugin host from {}: {error}", launch_spec.runtime_name, entry.display() - )), + )) })?; let client = host.client(); crate::plugin_host_http::register_plugin_host_backend_handlers(client.clone()).await?; - let plugins = config - .plugin - .iter() - .filter_map(plugin_declaration) - .collect::>(); - let configuration_fingerprint = plugin_config_fingerprint(&config)?; - *host_state = Some(host); - tokio::spawn(async move { - let plugin_count = plugins.len(); - log::info!( - "Configured plugin host background prewarm started: generation={}, plugin_count={}", - client.generation(), - plugin_count - ); - match client - .prepare_plugins( - PluginPrepareRequest { - plugins, - configuration_fingerprint: Some(configuration_fingerprint), - default_base_directory: None, - }, - std::time::Duration::from_secs(120), - ) - .await - { - Ok(result) => { - let prepared_count = result - .get("prepared") - .and_then(Value::as_array) - .map_or(0, Vec::len); - let failed_count = result - .get("failed") - .and_then(Value::as_array) - .map_or(0, Vec::len); - log::info!( - "Configured plugin host background prewarm completed: generation={}, plugin_count={}, prepared_count={}, failed_count={}", - client.generation(), - plugin_count, - prepared_count, - failed_count - ); - } - Err(error) => { - log::warn!( - "Configured plugin host background prewarm failed: generation={}, plugin_count={}, error={}", - client.generation(), - plugin_count, - error - ); - } - } - }); + *host_state.lock().await = Some(host); + start_plugin_host_health_monitor(client.clone()); + // Do not prepare or import configured packages during Host startup. The + // workspace-specific ensure path owns package resolution and generation + // publication. Configured plugin declarations are currently treated as + // trusted local executable input; follow-up hardening is tracked in the + // OpenCode compatibility design rather than represented by a non-enforcing + // approval API. Ok(PluginHostStartup::Started) } @@ -226,22 +331,60 @@ pub async fn set_configured_plugin_host_log_level(level: &str) -> crate::BitFunR }) } +/// Fault the current Host connection and reap its complete process tree. This +/// is used after a side-effecting invocation whose cancellation could not be +/// confirmed; closing the RPC socket alone is not a stop guarantee. +pub(crate) async fn fault_configured_plugin_host_generation( + expected_generation: u64, + reason: &str, +) -> bool { + let lifecycle_lock = PLUGIN_HOST_LIFECYCLE_LOCK + .get_or_init(|| async { Mutex::new(()) }) + .await; + let _lifecycle_guard = lifecycle_lock.lock().await; + let host = { + let Some(state) = PLUGIN_HOST.get() else { + return false; + }; + let mut state = state.lock().await; + if state + .as_ref() + .is_some_and(|host| host.client().generation() != expected_generation) + { + return false; + } + state.take() + }; + let Some(host) = host else { + return false; + }; + log::error!( + "Faulting configured plugin host and terminating its process tree: generation={}, reason={}", + host.client().generation(), + reason + ); + let report = host.shutdown(PluginHostShutdownPolicy::default()).await; + log::error!( + "Configured plugin host fault cleanup completed: generation={}, disposition={:?}, exit_code={:?}", + report.generation, + report.disposition, + report.exit_code + ); + true +} + pub async fn ensure_configured_plugin_instance( launch_policy: PluginHostLaunchPolicy, directory: PathBuf, worktree: PathBuf, project_id: Option, - config: Map, -) -> crate::BitFunResult> { +) -> crate::BitFunResult<()> { use crate::service::config::{get_global_config_service, GlobalConfig}; if launch_policy == PluginHostLaunchPolicy::Disabled { - return Ok(None); - } - let config_service = get_global_config_service().await?; - let global_config: GlobalConfig = config_service.get_config(None).await?; - if !global_config.has_configured_plugins() { - return Ok(None); + withdraw_configured_plugin_workspace(&directory).await; + clear_configured_plugin_activation_failure(Some(&directory)); + return Ok(()); } if directory.as_os_str().is_empty() || !directory.is_dir() { return Err(crate::BitFunError::Validation(format!( @@ -249,7 +392,6 @@ pub async fn ensure_configured_plugin_instance( directory.display() ))); } - let canonical_directory = dunce::canonicalize(&directory).map_err(|error| { crate::BitFunError::Io(std::io::Error::other(format!( "Failed to canonicalize plugin host instance directory {}: {error}", @@ -258,58 +400,115 @@ pub async fn ensure_configured_plugin_instance( })?; let canonical_directory_string = canonical_directory.to_string_lossy().into_owned(); let comparable_directory = comparable_instance_directory(&canonical_directory_string); + // All ensure/withdraw operations for a workspace use this same lock. The + // workspace snapshot, generation replacement, and publication therefore + // form one serialized transition for this directory. + let workspace_lock = plugin_host_workspace_lock(&comparable_directory).await; + let _workspace_guard = workspace_lock.lock().await; + + // Capture one immutable product configuration snapshot inside the + // workspace transition. Host initialization, package preparation, and the + // generation fingerprint must all describe this same declaration set. + let config_service = get_global_config_service().await?; + let global_config: GlobalConfig = config_service.get_config(None).await?; + if !global_config.has_configured_plugins() { + withdraw_configured_plugin_workspace_locked(&canonical_directory, &comparable_directory) + .await; + clear_configured_plugin_activation_failure(Some(&canonical_directory)); + return Ok(()); + } + initialize_configured_plugin_host_from_config(launch_policy, None, &global_config).await?; + clear_configured_plugin_activation_failure(None); + // Shutdown sets its gate before waiting for active ensure leases. Holding + // this lease through prepare/open/register/publish prevents teardown from + // clearing the instance table while a generation is still being published. + let _ensure_lease = acquire_plugin_host_ensure_lease().await?; + + let config = serde_json::to_value( + crate::plugin_runtime::opencode_config_snapshot(&canonical_directory).map_err(|error| { + crate::BitFunError::Validation(format!( + "Failed to load OpenCode config for plugin activation: {error}" + )) + })?, + ) + .and_then(|value| match value { + Value::Object(config) => Ok(config), + _ => unreachable!("OpenCodeConfigSnapshot must serialize as an object"), + }) + .map_err(|error| { + crate::BitFunError::Validation(format!( + "Failed to serialize OpenCode plugin config snapshot: {error}" + )) + })?; + let initial_config = config.clone(); let config_fingerprint = plugin_config_fingerprint(&global_config)?; - let client = { + let (client, runtime) = { let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; host_state .lock() .await .as_ref() - .map(PluginHost::client) + .map(|host| (host.client(), host.runtime())) .ok_or_else(|| { crate::BitFunError::ProcessError( "Configured plugin host is not running".to_string(), ) })? }; + if !client.capabilities().supports(GENERATION_FENCING_V1) { + return Err(crate::BitFunError::ProcessError( + "Configured plugin host does not support generation-fencing-v1".to_string(), + )); + } let instances = PLUGIN_HOST_INSTANCES .get_or_init(|| async { Mutex::new(HashMap::new()) }) .await; - let instance_key = format!("{comparable_directory}\n{config_fingerprint}"); - if let Some(instance) = instances.lock().await.get(&instance_key).cloned() { - log::debug!( - "Configured plugin host instance reused: generation={}, instance_id={}", - client.generation(), - instance.instance_id - ); - return Ok(Some(instance.open_result.clone())); - } - - let previous_keys = instances - .lock() - .await + let declarations = global_config + .plugin .iter() - .filter(|(_, instance)| instance.canonical_directory == comparable_directory) - .map(|(key, instance)| (key.clone(), instance.instance_id.clone())) + .filter_map(plugin_declaration) .collect::>(); - for (key, instance_id) in previous_keys { - if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { - bridge.cancel_instance_streams(&instance_id).await; - } - client - .close_instance(&instance_id, std::time::Duration::from_secs(10)) - .await - .map_err(|error| { - crate::BitFunError::ProcessError(format!( - "Failed to close stale plugin host instance {instance_id}: {error}" - )) - })?; - close_plugin_host_ptys(&instance_id).await; - instances.lock().await.remove(&key); + // Resolve the configured declarations directly. Plugin activation is an + // explicit BitFun configuration choice; no separate external-integration + // policy, safe-mode switch, or activation approval is required before the + // host can load the configured plugins. + let prepared = client + .prepare_plugins( + PluginPrepareRequest { + plugins: declarations.clone(), + configuration_fingerprint: Some(config_fingerprint.clone()), + default_base_directory: Some(canonical_directory_string.clone()), + allow_install: Some(true), + }, + std::time::Duration::from_secs(30), + ) + .await + .map_err(|error| { + crate::BitFunError::ProcessError(format!( + "Failed to prepare plugins for workspace {}: {error}", + canonical_directory.display() + )) + })?; + let prepared_count = prepared.prepared_count; + let failed_count = prepared.failed_count; + let reviewed_count = prepared.reviewed_count; + if failed_count != 0 || prepared_count != reviewed_count { + return Err(crate::BitFunError::Validation(format!( + "Configured OpenCode plugin preparation did not resolve the complete plugin graph: prepared={prepared_count}, failed={failed_count}, reviewed={reviewed_count}" + ))); } + // Use the adapter's stable digest for generation identity. Cache state and + // diagnostic prose are operational details and must not churn a generation. + let prepared_fingerprint = prepared.review_digest; + let expected_content_digests = prepared.content_digests; - let sequence = NEXT_INSTANCE_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let instance_id = format!("bitfun:host:{}:{sequence}", client.generation()); + let workspace_config_fingerprint = serde_json::to_vec(&initial_config) + .map(|bytes| hex::encode(Sha256::digest(bytes))) + .map_err(|error| { + crate::BitFunError::Validation(format!( + "Failed to fingerprint workspace plugin config: {error}" + )) + })?; let project_id = project_id .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { @@ -318,6 +517,91 @@ pub async fn ensure_configured_plugin_instance( hex::encode(Sha256::digest(canonical_directory_string.as_bytes())) ) }); + let instance_key = format!( + "{comparable_directory}\n{project_id}\n{config_fingerprint}\n{workspace_config_fingerprint}\n{prepared_fingerprint}" + ); + let reusable_instance = { + let mut state = instances.lock().await; + state + .get_mut(&instance_key) + .filter(|instance| instance.ready && instance.host_generation == client.generation()) + .map(|instance| { + instance.retirement_scheduled = false; + instance.clone() + }) + }; + if let Some(instance) = reusable_instance { + if crate::plugin_config_projection::active_generation_key(&canonical_directory).as_deref() + != Some(instance.generation_key.as_str()) + { + let projection = crate::plugin_config_projection::prepare( + &canonical_directory, + &instance.generation_key, + &initial_config, + instance.registration_batch.as_ref().ok_or_else(|| { + crate::BitFunError::ProcessError( + "Reusable plugin instance is missing its typed registration batch" + .to_string(), + ) + })?, + )?; + crate::plugin_hook_bridge::commit_plugin_generation( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + instance.hook_commit_token.as_ref(), + ); + projection.commit(); + } + log::debug!( + "Configured plugin host instance reused: generation={}, instance_id={}", + client.generation(), + instance.instance_id + ); + if !retire_superseded_plugin_instances( + &client, + runtime.clone(), + instances, + &instance_key, + &comparable_directory, + ) + .await + { + return Err(crate::BitFunError::ProcessError( + "Configured plugin Host faulted while retiring a superseded generation".to_string(), + )); + } + clear_configured_plugin_activation_failure(Some(&canonical_directory)); + return Ok(()); + } + + // The extension host owns one canonical directory at a time. Stop and + // remove the old logical generation before opening the replacement so the + // host cannot reject the new instance with directory_exists or run old and + // new plugin code concurrently. + if !retire_workspace_instances_before_open( + &client, + runtime.clone(), + instances, + &comparable_directory, + ) + .await + { + return Err(crate::BitFunError::ProcessError( + "Configured plugin host could not confirm closure of the previous workspace generation" + .to_string(), + )); + } + + let sequence = NEXT_INSTANCE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let instance_id = format!("bitfun:host:{}:{sequence}", client.generation()); + let revision = format!("revision-{sequence}"); + let generation_material = + format!("{config_fingerprint}\n{workspace_config_fingerprint}\n{prepared_fingerprint}"); + let generation_key = format!( + "host-{}:instance-{sequence}:sha256-{}", + client.generation(), + hex::encode(Sha256::digest(generation_material.as_bytes())) + ); let now_ms = chrono::Utc::now().timestamp_millis(); let opening_context = PluginHostInstance { canonical_directory: comparable_directory.clone(), @@ -326,57 +610,718 @@ pub async fn ensure_configured_plugin_instance( project_id: project_id.clone(), created_at_ms: now_ms, instance_id: instance_id.clone(), - open_result: Value::Null, + host_generation: client.generation(), + generation_key: generation_key.clone(), + revision: revision.clone(), + registration_batch: None, ready: false, + hook_commit_token: None, + transformed_config_health_snapshot: None, + diagnostic_health_snapshot: Vec::new(), + tool_names: Vec::new(), + agent_runtime_keys: Vec::new(), + retirement_scheduled: false, }; instances .lock() .await .insert(instance_key.clone(), opening_context); - let open_result = match client - .open_instance( - PluginInstanceOpenRequest { - instance_id: instance_id.clone(), - project: serde_json::json!({ - "id": project_id, - "worktree": canonical_directory_string, - "time": {"created": now_ms}, - }), + let generation = HookFunctionGeneration { + instance_id: instance_id.clone(), + generation_key: generation_key.clone(), + revision: revision.clone(), + }; + let registrations = Arc::new(CapturedPluginGeneration::default()); + let started = runtime + .start( + HookFunctionStartRequest { + generation: generation.clone(), + project_id: project_id.clone(), + project_worktree: canonical_directory_string.clone(), + project_created_at_ms: u64::try_from(now_ms).unwrap_or_default(), config, directory: canonical_directory.to_string_lossy().into_owned(), worktree: worktree.to_string_lossy().into_owned(), - plugins: global_config - .plugin - .iter() - .filter_map(plugin_declaration) + plugins: declarations + .into_iter() + .map(|plugin| HookFunctionPluginDeclaration { + spec: plugin.spec, + options: plugin.options, + base_directory: plugin.base_directory, + }) .collect(), configuration_fingerprint: Some(config_fingerprint.clone()), + expected_content_digests, + expected_review_digest: Some(prepared_fingerprint.clone()), }, + registrations.clone(), + crate::agentic::tools::plugin_host_tool::reverse_sink(), std::time::Duration::from_secs(30), ) - .await - { - Ok(result) => result, + .await; + match started { + Ok(started) if started == generation => {} + Ok(_) => { + discard_opening_plugin_instance( + &client, + runtime.clone(), + instances, + &instance_key, + &generation, + ) + .await; + return Err(crate::BitFunError::ProcessError( + "Plugin runtime returned a different generation from the requested lease" + .to_string(), + )); + } Err(error) => { - close_plugin_host_ptys(&instance_id).await; - instances.lock().await.remove(&instance_key); + discard_opening_plugin_instance( + &client, + runtime.clone(), + instances, + &instance_key, + &generation, + ) + .await; return Err(crate::BitFunError::ProcessError(format!( "Failed to activate plugins for workspace {}: {error}", canonical_directory.display() ))); } + } + let registration_batch = match registrations.take() { + Ok(batch) => batch, + Err(error) => { + discard_opening_plugin_instance( + &client, + runtime.clone(), + instances, + &instance_key, + &generation, + ) + .await; + return Err(error); + } + }; + let config_projection = match crate::plugin_config_projection::prepare( + &canonical_directory, + &generation_key, + &initial_config, + ®istration_batch, + ) { + Ok(projection) => projection, + Err(error) => { + discard_opening_plugin_instance( + &client, + runtime.clone(), + instances, + &instance_key, + &generation, + ) + .await; + return Err(error); + } }; + let plugin_agent_runtime_keys = config_projection.agent_runtime_keys(); log::info!( - "Configured plugin host instance activated: generation={}, instance_id={}, plugin_count={}", + "Configured plugin host instance prepared: generation={}, instance_id={}, plugin_count={}", client.generation(), instance_id, global_config.plugin.len() ); - if let Some(instance) = instances.lock().await.get_mut(&instance_key) { - instance.open_result = open_result.clone(); + let hook_commit_token = match crate::plugin_hook_bridge::register_plugin_hooks_with_runtime( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + runtime.clone(), + &instance_id, + &generation_key, + &revision, + &crate::plugin_hook_bridge::hook_names(®istration_batch), + ) { + Ok(token) => token, + Err(error) => { + discard_opening_plugin_instance( + &client, + runtime.clone(), + instances, + &instance_key, + &generation, + ) + .await; + return Err(crate::BitFunError::ProcessError(format!( + "Failed to register plugin hooks for workspace {}: {error}", + canonical_directory.display() + ))); + } + }; + let tool_names = match register_plugin_tools( + runtime.clone(), + client.generation(), + &instance_id, + &comparable_directory, + &canonical_directory, + &generation_key, + &revision, + &config_fingerprint, + ®istration_batch, + &config_projection, + ) + .await + { + Ok(names) => names, + Err(error) => { + if let Some(token) = hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + token, + ); + } + discard_opening_plugin_instance( + &client, + runtime.clone(), + instances, + &instance_key, + &generation, + ) + .await; + return Err(error); + } + }; + // Publish readiness, Hooks, and Config routes while holding the instance + // table lock. Hook dispatch cannot observe ready=true before its Registry + // generation is active, and Agent routing is published last, after the + // instance identity is available to generation-fenced dispatch. + { + let mut state = instances.lock().await; + if !state.contains_key(&instance_key) { + drop(state); + if let Some(token) = hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + token, + ); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + &comparable_directory, + &canonical_directory, + &tool_names, + &generation_key, + ) + .await; + discard_opening_plugin_instance( + &client, + runtime.clone(), + instances, + &instance_key, + &generation, + ) + .await; + return Err(crate::BitFunError::ProcessError( + "Plugin instance disappeared before generation publication".to_string(), + )); + } + let instance = state + .get_mut(&instance_key) + .expect("plugin instance existence checked while holding instance table lock"); + instance.registration_batch = Some(registration_batch.clone()); instance.ready = true; + instance.hook_commit_token = hook_commit_token.clone(); + instance.transformed_config_health_snapshot = + Some(Value::Object(registration_batch.config.clone())); + instance.tool_names = tool_names; + instance.agent_runtime_keys = plugin_agent_runtime_keys.into_iter().collect(); + crate::plugin_hook_bridge::commit_plugin_generation( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + hook_commit_token.as_ref(), + ); + config_projection.commit(); + } + if !retire_superseded_plugin_instances( + &client, + runtime, + instances, + &instance_key, + &comparable_directory, + ) + .await + { + return Err(crate::BitFunError::ProcessError( + "Configured plugin Host faulted while retiring a superseded generation".to_string(), + )); + } + clear_configured_plugin_activation_failure(Some(&canonical_directory)); + Ok(()) +} + +async fn discard_opening_plugin_instance( + client: &bitfun_opencode_plugin_host::PluginHostClient, + runtime: Arc, + instances: &Mutex>, + instance_key: &str, + generation: &HookFunctionGeneration, +) { + if !dispose_plugin_generation(client, runtime, generation, "failed plugin prepare").await { + log::debug!( + "Plugin instance cleanup after failed prepare faulted the Host: instance_id={}", + generation.instance_id + ); + } + close_plugin_host_ptys(&generation.instance_id).await; + let mut state = instances.lock().await; + if state + .get(instance_key) + .is_some_and(|current| current.instance_id == generation.instance_id) + { + state.remove(instance_key); + } +} + +async fn retire_workspace_instances_before_open( + client: &bitfun_opencode_plugin_host::PluginHostClient, + runtime: Arc, + instances: &Mutex>, + workspace_scope: &str, +) -> bool { + let stale = { + let state = instances.lock().await; + let keys = state + .iter() + .filter(|(_, instance)| instance.canonical_directory == workspace_scope) + .map(|(key, instance)| (key.clone(), instance.instance_id.clone())) + .collect::>(); + keys.into_iter() + .filter_map(|(key, instance_id)| { + state + .get(&key) + .filter(|current| current.instance_id == instance_id) + .cloned() + .map(|instance| (key, instance)) + }) + .collect::>() + }; + let mut all_closed = true; + for (key, instance) in stale { + if retire_plugin_instance(client, runtime.clone(), instance.clone(), workspace_scope).await + { + let mut state = instances.lock().await; + if state + .get(&key) + .is_some_and(|current| current.instance_id == instance.instance_id) + { + state.remove(&key); + } + } else if let Some(current) = instances.lock().await.get_mut(&key) { + if current.instance_id == instance.instance_id { + current.ready = false; + } + all_closed = false; + } + } + all_closed +} + +async fn withdraw_configured_plugin_workspace(directory: &Path) { + let Ok(canonical) = dunce::canonicalize(directory) else { + return; + }; + let workspace_scope = comparable_instance_directory(&canonical.to_string_lossy()); + let workspace_lock = plugin_host_workspace_lock(&workspace_scope).await; + let _workspace_guard = workspace_lock.lock().await; + withdraw_configured_plugin_workspace_locked(&canonical, &workspace_scope).await; +} + +async fn withdraw_configured_plugin_workspace_locked(canonical: &Path, workspace_scope: &str) { + let registry = crate::native_hooks::plugin_hook_registry(&workspace_scope); + crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace_scope); + crate::plugin_config_projection::release_workspace(canonical); + let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { + crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); + return; + }; + let owned = instances + .lock() + .await + .iter() + .filter(|(_, instance)| instance.canonical_directory == workspace_scope) + .map(|(key, instance)| (key.clone(), instance.clone())) + .collect::>(); + let host_runtime = if let Some(state) = PLUGIN_HOST.get() { + state + .lock() + .await + .as_ref() + .map(|host| (host.client(), host.runtime())) + } else { + None + }; + for (key, instance) in owned { + if let Some(token) = instance.hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks(®istry, &workspace_scope, token); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + &workspace_scope, + &instance.directory, + &instance.tool_names, + &instance.generation_key, + ) + .await; + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.cancel_instance_streams(&instance.instance_id).await; + } + let close_result = if let Some((client, runtime)) = host_runtime.as_ref() { + dispose_plugin_generation( + client, + runtime.clone(), + &instance.generation(), + "workspace withdrawal", + ) + .await + .then_some(()) + .ok_or_else(|| "plugin Host faulted during workspace withdrawal".to_string()) + } else { + Err("plugin host is unavailable during workspace withdrawal".to_string()) + }; + close_plugin_host_ptys(&instance.instance_id).await; + let mut state = instances.lock().await; + if let Some(current) = state.get_mut(&key) { + if current.instance_id != instance.instance_id { + continue; + } + if let Err(error) = close_result { + current.ready = false; + log::error!( + "Plugin instance withdrawal could not confirm Host close; retaining fault state: instance_id={}, error={}", + current.instance_id, + error + ); + } else { + state.remove(&key); + } + } + } + crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); +} + +async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_generation: u64) { + let Ok(canonical) = dunce::canonicalize(directory) else { + return; + }; + let workspace_scope = comparable_instance_directory(&canonical.to_string_lossy()); + let workspace_lock = plugin_host_workspace_lock(&workspace_scope).await; + let _workspace_guard = workspace_lock.lock().await; + let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { + return; + }; + let owned = instances + .lock() + .await + .iter() + .filter(|(_, instance)| { + instance.canonical_directory == workspace_scope + && instance.host_generation == expected_generation + }) + .map(|(key, instance)| (key.clone(), instance.clone())) + .collect::>(); + let registry = crate::native_hooks::plugin_hook_registry(&workspace_scope); + for (key, instance) in owned { + if let Some(token) = instance.hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks(®istry, &workspace_scope, token); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + &workspace_scope, + &instance.directory, + &instance.tool_names, + &instance.generation_key, + ) + .await; + if crate::plugin_config_projection::active_generation_key(&instance.directory).as_deref() + == Some(instance.generation_key.as_str()) + { + crate::plugin_config_projection::release_workspace(&instance.directory); + } + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.cancel_instance_streams(&instance.instance_id).await; + } + close_plugin_host_ptys(&instance.instance_id).await; + let mut state = instances.lock().await; + if state.get(&key).is_some_and(|current| { + current.instance_id == instance.instance_id + && current.host_generation == expected_generation + }) { + state.remove(&key); + } + } + let has_replacement = instances.lock().await.values().any(|instance| { + instance.canonical_directory == workspace_scope + && instance.host_generation != expected_generation + }); + if !has_replacement { + crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace_scope); + crate::plugin_config_projection::release_workspace(&canonical); + crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); + } +} + +fn start_plugin_host_health_monitor(client: bitfun_opencode_plugin_host::PluginHostClient) { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let (connected, owns_host_slot) = { + let Some(host_state) = PLUGIN_HOST.get() else { + return; + }; + let mut host_state = host_state.lock().await; + match host_state.as_mut() { + Some(host) if host.client().generation() != client.generation() => { + (false, false) + } + Some(host) => match host.is_connected() { + Ok(connected) => (connected, true), + Err(error) => { + log::error!( + "Configured plugin host health check failed: generation={}, error={}", + client.generation(), + error + ); + (false, true) + } + }, + None => (false, false), + } + }; + if connected && !client.is_closed() { + continue; + } + log::error!( + "Configured plugin host process or connection closed; withdrawing all plugin contributions: generation={}", + client.generation() + ); + // Another outcome-unknown path may already have reaped this + // physical Host. Local routes still belong to this dead generation + // and must be withdrawn; the cleanup below is generation-scoped + // and therefore cannot remove a replacement Host's contributions. + if owns_host_slot { + fault_configured_plugin_host_generation( + client.generation(), + "host connection or process lost", + ) + .await; + } + let workspaces = PLUGIN_HOST_INSTANCES.get().map(|instances| async { + instances + .lock() + .await + .values() + .filter(|instance| instance.host_generation == client.generation()) + .map(|instance| instance.directory.clone()) + .collect::>() + }); + let Some(workspaces) = workspaces else { return }; + let workspaces = workspaces.await; + for workspace in workspaces { + withdraw_faulted_plugin_host_generation(&workspace, client.generation()).await; + } + return; + } + }); +} + +async fn retire_superseded_plugin_instances( + client: &bitfun_opencode_plugin_host::PluginHostClient, + runtime: Arc, + instances: &Mutex>, + active_key: &str, + workspace_scope: &str, +) -> bool { + let stale = instances + .lock() + .await + .iter() + .filter(|(key, instance)| { + key.as_str() != active_key && instance.canonical_directory == workspace_scope + }) + .map(|(key, instance)| (key.clone(), instance.clone())) + .collect::>(); + for (key, instance) in stale { + if instance.agent_runtime_keys.iter().any(|runtime_key| { + crate::agentic::agents::get_agent_registry().check_agent_exists(runtime_key) + }) { + let should_schedule = { + let mut state = instances.lock().await; + state.get_mut(&key).is_some_and(|current| { + if current.retirement_scheduled { + false + } else { + current.retirement_scheduled = true; + true + } + }) + }; + if should_schedule { + schedule_plugin_instance_retirement(client.clone(), runtime.clone(), key.clone()); + } + continue; + } + let removed = { + let mut state = instances.lock().await; + state + .get(&key) + .filter(|current| current.instance_id == instance.instance_id) + .is_some() + .then(|| state.remove(&key)) + .flatten() + }; + if let Some(removed) = removed { + if !retire_plugin_instance(client, runtime.clone(), removed, workspace_scope).await { + return false; + } + } + } + true +} + +fn schedule_plugin_instance_retirement( + client: bitfun_opencode_plugin_host::PluginHostClient, + runtime: Arc, + instance_key: String, +) { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { + return; + }; + let snapshot = { + let state = instances.lock().await; + let Some(instance) = state.get(&instance_key) else { + return; + }; + if !instance.retirement_scheduled { + return; + } + instance.clone() + }; + if crate::plugin_config_projection::active_generation_key(&snapshot.directory) + .as_deref() + == Some(snapshot.generation_key.as_str()) + { + if let Some(instance) = instances.lock().await.get_mut(&instance_key) { + instance.retirement_scheduled = false; + } + return; + } + if snapshot.agent_runtime_keys.iter().any(|runtime_key| { + crate::agentic::agents::get_agent_registry().check_agent_exists(runtime_key) + }) { + continue; + } + let removed = { + let mut state = instances.lock().await; + let matches = state.get(&instance_key).is_some_and(|current| { + current.retirement_scheduled + && current.instance_id == snapshot.instance_id + && current.generation_key == snapshot.generation_key + }); + matches.then(|| state.remove(&instance_key)).flatten() + }; + if let Some(instance) = removed { + let workspace_scope = instance.canonical_directory.clone(); + let _ = + retire_plugin_instance(&client, runtime.clone(), instance, &workspace_scope) + .await; + } + return; + } + }); +} + +async fn retire_plugin_instance( + client: &bitfun_opencode_plugin_host::PluginHostClient, + runtime: Arc, + instance: PluginHostInstance, + workspace_scope: &str, +) -> bool { + if let Some(token) = instance.hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(workspace_scope), + workspace_scope, + token, + ); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + workspace_scope, + &instance.directory, + &instance.tool_names, + &instance.generation_key, + ) + .await; + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.cancel_instance_streams(&instance.instance_id).await; + } + let closed = if instance.host_generation != client.generation() { + // A connection-generation change fences every instance owned by the + // dead Host. There is no valid RPC target left to close; treating this + // as closed allows the next Host generation to recover the workspace. + true + } else { + dispose_plugin_generation( + client, + runtime, + &instance.generation(), + "superseded generation retirement", + ) + .await + }; + close_plugin_host_ptys(&instance.instance_id).await; + if closed { + crate::plugin_config_projection::release_workspace_generation( + &instance.directory, + &instance.generation_key, + ); + } + closed +} + +async fn dispose_plugin_generation( + client: &bitfun_opencode_plugin_host::PluginHostClient, + runtime: Arc, + generation: &HookFunctionGeneration, + reason: &str, +) -> bool { + match runtime + .dispose( + HookFunctionDisposeRequest { + generation: generation.clone(), + }, + std::time::Duration::from_secs(10), + ) + .await + { + Ok(result) if result.closed => true, + Ok(_) => { + log::error!( + "Plugin runtime did not confirm generation disposal: instance_id={}, reason={}", + generation.instance_id, + reason + ); + fault_configured_plugin_host_generation(client.generation(), reason).await; + false + } + Err(error) => { + log::error!( + "Plugin runtime generation disposal failed: instance_id={}, reason={}, error={}", + generation.instance_id, + reason, + error + ); + fault_configured_plugin_host_generation(client.generation(), reason).await; + false + } } - Ok(Some(open_result)) } pub(crate) async fn plugin_host_instance_by_id(instance_id: &str) -> Option { @@ -389,6 +1334,189 @@ pub(crate) async fn plugin_host_instance_by_id(instance_id: &str) -> Option Option { + let instances = PLUGIN_HOST_INSTANCES.get()?; + instances + .lock() + .await + .values() + .find(|instance| { + instance.ready + && instance.canonical_directory == workspace_scope + && instance + .agent_runtime_keys + .iter() + .any(|key| key == runtime_agent_key) + }) + .map( + |instance| bitfun_agent_runtime::native_hooks::PluginHookGenerationIdentity { + instance_id: instance.instance_id.clone(), + generation_key: instance.generation_key.clone(), + revision: instance.revision.clone(), + }, + ) +} + +pub(crate) async fn publish_plugin_host_diagnostic( + event: BackendDiagnosticEvent, +) -> Result<(), BackendDiagnosticError> { + let diagnostic = serde_json::json!({ + "severity": event.diagnostic.severity.as_str(), + "code": event.diagnostic.code, + "message": event.diagnostic.message, + "plugin": event.diagnostic.plugin, + "method": event.diagnostic.method, + "data": event.diagnostic.data, + }); + if let Some(instance_id) = event.instance_id.as_deref() { + let instances = PLUGIN_HOST_INSTANCES.get().ok_or_else(|| { + BackendDiagnosticError::Unavailable("plugin instance is unavailable".to_string()) + })?; + let mut instances = instances.lock().await; + let instance = instances + .values_mut() + .find(|instance| instance.instance_id == instance_id) + .ok_or_else(|| { + BackendDiagnosticError::Unavailable("plugin instance is unavailable".to_string()) + })?; + push_plugin_host_diagnostic(&mut instance.diagnostic_health_snapshot, diagnostic.clone()); + } + crate::infrastructure::events::emit_global_event( + crate::infrastructure::events::BackendEvent::Custom { + event_name: "plugin-host-diagnostic".to_string(), + payload: serde_json::json!({ + "instance_id": event.instance_id, + "diagnostic": diagnostic, + "timestamp": chrono::Utc::now().timestamp_millis(), + }), + }, + ) + .await + .map_err(|error| { + BackendDiagnosticError::Backend(format!( + "failed to publish plugin host diagnostic: {error}" + )) + })?; + Ok(()) +} + +fn plugin_activation_diagnostic( + operation: &str, + workspace: Option<&Path>, + error: &str, +) -> BackendDiagnosticEvent { + BackendDiagnosticEvent { + instance_id: None, + diagnostic: BackendDiagnostic { + severity: BackendDiagnosticSeverity::Warning, + code: "plugin.activation_failed".to_string(), + message: error.to_string(), + plugin: None, + method: Some(operation.to_string()), + data: workspace + .map(|workspace| serde_json::json!({"workspace": workspace.to_string_lossy()})), + }, + } +} + +/// Report an optional configured-plugin activation failure without changing +/// the outcome of the native session operation that triggered it. +pub async fn report_configured_plugin_activation_failure( + operation: &str, + workspace: Option<&Path>, + error: impl std::fmt::Display, +) { + let error = error.to_string(); + record_configured_plugin_activation_failure(operation, workspace, &error); + log::warn!( + "Configured plugin activation failed; continuing with native capabilities: operation={}, workspace={}, error={}", + operation, + workspace + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()), + error + ); + if let Err(publish_error) = + publish_plugin_host_diagnostic(plugin_activation_diagnostic(operation, workspace, &error)) + .await + { + log::debug!( + "Configured plugin activation diagnostic could not be published: {:?}", + publish_error + ); + } +} + +fn plugin_activation_workspace_key(workspace: Option<&Path>) -> String { + workspace + .map(|path| { + canonical_plugin_workspace_scope(path) + .unwrap_or_else(|| comparable_instance_directory(&path.to_string_lossy())) + }) + .unwrap_or_else(|| "".to_string()) +} + +fn activation_failure_store() -> &'static std::sync::RwLock> { + PLUGIN_ACTIVATION_FAILURES.get_or_init(|| std::sync::RwLock::new(HashMap::new())) +} + +fn record_configured_plugin_activation_failure( + operation: &str, + workspace: Option<&Path>, + error: &str, +) { + let mut message = format!("Configured plugin activation failed during {operation}: {error}"); + if message.len() > 2048 { + let boundary = message + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= 2045) + .last() + .unwrap_or(0); + message.truncate(boundary); + message.push_str("..."); + } + activation_failure_store() + .write() + .expect("plugin activation failure lock poisoned") + .insert(plugin_activation_workspace_key(workspace), message); +} + +fn clear_configured_plugin_activation_failure(workspace: Option<&Path>) { + activation_failure_store() + .write() + .expect("plugin activation failure lock poisoned") + .remove(&plugin_activation_workspace_key(workspace)); +} + +pub(crate) fn configured_plugin_activation_failures(workspace: Option<&Path>) -> Vec { + let failures = activation_failure_store() + .read() + .expect("plugin activation failure lock poisoned"); + let mut result = Vec::new(); + if let Some(global) = failures.get("") { + result.push(global.clone()); + } + let workspace_key = plugin_activation_workspace_key(workspace); + if workspace_key != "" { + if let Some(workspace) = failures.get(&workspace_key) { + result.push(workspace.clone()); + } + } + result +} + +fn push_plugin_host_diagnostic(snapshot: &mut Vec, diagnostic: Value) { + snapshot.push(diagnostic); + let overflow = snapshot.len().saturating_sub(MAX_PLUGIN_HOST_DIAGNOSTICS); + if overflow > 0 { + snapshot.drain(..overflow); + } +} + pub(crate) async fn register_plugin_host_pty(pty_id: &str, instance_id: &str) { let owners = PLUGIN_HOST_PTY_OWNERS .get_or_init(|| async { Mutex::new(HashMap::new()) }) @@ -537,13 +1665,55 @@ pub async fn shutdown_configured_plugin_host( } } + let lifecycle_lock = PLUGIN_HOST_LIFECYCLE_LOCK + .get_or_init(|| async { Mutex::new(()) }) + .await; + // Cross the lifecycle mutex once after closing the gate so an ensure can + // no longer pass its final shutdown check and acquire a publication lease. + { + let _lifecycle_barrier = lifecycle_lock.lock().await; + } + wait_for_plugin_host_ensure_leases().await; + let _lifecycle_guard = lifecycle_lock.lock().await; + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { - bridge.begin_draining().await; + if !bridge.begin_draining().await { + log::error!( + "Plugin Host backend requests did not stop before shutdown teardown; no replacement generation will be started" + ); + } } let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; let host = host_state.lock().await.take(); if let Some(instances) = PLUGIN_HOST_INSTANCES.get() { - instances.lock().await.clear(); + let mut instances = instances.lock().await; + for instance in instances.values() { + if let Some(token) = instance.hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(&instance.canonical_directory), + &instance.canonical_directory, + token, + ); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + &instance.canonical_directory, + &instance.directory, + &instance.tool_names, + &instance.generation_key, + ) + .await; + crate::plugin_config_projection::release_workspace(&instance.directory); + } + let workspaces = instances + .values() + .map(|instance| instance.canonical_directory.clone()) + .collect::>(); + for workspace in workspaces { + let registry = crate::native_hooks::plugin_hook_registry(&workspace); + crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace); + crate::native_hooks::clear_plugin_hook_workspace(&workspace); + } + instances.clear(); } let report = match host { Some(host) => { @@ -565,6 +1735,92 @@ pub async fn shutdown_configured_plugin_host( Ok(report) } +async fn register_plugin_tools( + runtime: Arc, + host_generation: u64, + instance_id: &str, + workspace_scope: &str, + workspace_root: &Path, + generation_key: &str, + revision: &str, + config_fingerprint: &str, + registration_batch: &HookFunctionRegistrationBatch, + projection: &crate::plugin_config_projection::PluginConfigProjectionPlan, +) -> crate::BitFunResult> { + let tools = ®istration_batch.tools; + if tools.is_empty() { + log::debug!( + "Plugin tool registration completed with no tools: workspace={}, instance_id={}", + workspace_scope, + instance_id + ); + return Ok(Vec::new()); + } + log::debug!( + "Plugin tool registration preparing: workspace={}, instance_id={}, tool_count={}", + workspace_scope, + instance_id, + tools.len() + ); + let mut prepared = Vec::new(); + let mut seen_ids = std::collections::BTreeSet::new(); + for tool in tools { + let allowed_runtime_agent_keys = projection.allowed_runtime_agent_keys_for_tool(tool)?; + if !seen_ids.insert(tool.id.clone()) { + return Err(crate::BitFunError::Validation(format!( + "Plugin tool id is duplicated in the registration batch: {}", + tool.id + ))); + } + prepared.push(( + tool.registration_id.clone(), + tool.id.clone(), + tool.description.clone(), + tool.parameters.clone(), + allowed_runtime_agent_keys, + )); + } + + // Validate the complete generation before mutating the Tool mux. Once + // registration starts, all remaining operations are infallible local + // publication steps, so a malformed later entry cannot leave a partial + // generation installed. + let mut names = Vec::with_capacity(prepared.len()); + for (registration_id, id, description, parameters, allowed_runtime_agent_keys) in prepared { + crate::agentic::tools::plugin_host_tool::register_workspace_tool( + workspace_scope, + workspace_root, + runtime.clone(), + host_generation, + instance_id, + generation_key, + revision, + ®istration_id, + &id, + &description, + parameters, + config_fingerprint, + allowed_runtime_agent_keys, + ) + .await; + log::debug!( + "Plugin tool registration committed to Rust registry: workspace={}, instance_id={}, tool_id={}, registration_id={}", + workspace_scope, + instance_id, + id, + registration_id + ); + names.push(id); + } + log::info!( + "Plugin tool registration completed: workspace={}, instance_id={}, tool_count={}", + workspace_scope, + instance_id, + names.len() + ); + Ok(names) +} + fn resolve_host_entry(spec: PluginHostLaunchSpec) -> crate::BitFunResult { if let Some(entry) = std::env::var_os(spec.entry_env) { return absolutize_existing_entry(PathBuf::from(entry), spec); @@ -576,12 +1832,9 @@ fn resolve_host_entry(spec: PluginHostLaunchSpec) -> crate::BitFunResult crate::BitFunResult Vec { + let Some(executable_directory) = executable.parent() else { + return Vec::new(); + }; + let relative_entry = Path::new("resources") + .join("ext-host") + .join(spec.entry_filename); + let mut candidates = vec![executable_directory.join(&relative_entry)]; + if let Some(parent) = executable_directory.parent() { + candidates.push(parent.join("Resources").join(&relative_entry)); + if let Some(binary_name) = executable.file_name() { + candidates.push(parent.join("lib").join(binary_name).join(&relative_entry)); + candidates.push(parent.join("share").join(binary_name).join(&relative_entry)); + } + } + candidates +} + fn development_host_entry(spec: PluginHostLaunchSpec) -> Option { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() @@ -652,6 +1926,12 @@ fn comparable_instance_directory(directory: &str) -> String { comparable } +pub(crate) fn canonical_plugin_workspace_scope(path: &Path) -> Option { + dunce::canonicalize(path) + .ok() + .map(|path| comparable_instance_directory(&path.to_string_lossy())) +} + fn absolutize_existing_entry( entry: PathBuf, spec: PluginHostLaunchSpec, @@ -677,10 +1957,11 @@ fn absolutize_existing_entry( #[cfg(test)] mod tests { use super::{ - development_host_entry, initialize_configured_plugin_host, instance_directories_equal, - plugin_host_pty_ids_for_instance, plugin_host_pty_owned_by, register_plugin_host_pty, + bundled_host_entry_candidates, development_host_entry, initialize_configured_plugin_host, + instance_directories_equal, plugin_activation_diagnostic, plugin_host_pty_ids_for_instance, + plugin_host_pty_owned_by, push_plugin_host_diagnostic, register_plugin_host_pty, unregister_plugin_host_pty, PluginHostLaunchPolicy, PluginHostLaunchSpec, - PluginHostStartup, + PluginHostStartup, MAX_PLUGIN_HOST_DIAGNOSTICS, }; use std::path::Path; @@ -708,6 +1989,21 @@ mod tests { )); } + #[test] + fn bundled_host_entry_supports_desktop_platform_layouts() { + let entries = bundled_host_entry_candidates( + Path::new("product/bin/bitfun-desktop"), + PluginHostLaunchSpec::bun(), + ); + + assert!(entries + .iter() + .any(|entry| entry.ends_with(Path::new("bin/resources/ext-host/extension-host.js")))); + assert!(entries.iter().any(|entry| entry.ends_with(Path::new( + "product/Resources/resources/ext-host/extension-host.js" + )))); + } + #[tokio::test] async fn disabled_launch_policy_skips_host_initialization() { let status = initialize_configured_plugin_host(PluginHostLaunchPolicy::Disabled) @@ -748,4 +2044,55 @@ mod tests { ); assert!(unregister_plugin_host_pty(&pty_id, &first).await); } + + #[test] + fn diagnostic_health_snapshot_retains_the_newest_entries() { + let mut snapshot = Vec::new(); + for index in 0..=MAX_PLUGIN_HOST_DIAGNOSTICS { + push_plugin_host_diagnostic(&mut snapshot, serde_json::json!({"index": index})); + } + + assert_eq!(snapshot.len(), MAX_PLUGIN_HOST_DIAGNOSTICS); + assert_eq!(snapshot.first().unwrap()["index"], 1); + assert_eq!( + snapshot.last().unwrap()["index"], + MAX_PLUGIN_HOST_DIAGNOSTICS + ); + } + + #[test] + fn activation_failure_diagnostic_is_stable_and_workspace_scoped() { + let event = plugin_activation_diagnostic( + "session creation", + Some(Path::new("C:/workspace/project")), + "Bun executable was not found", + ); + + assert_eq!(event.diagnostic.code, "plugin.activation_failed"); + assert_eq!(event.diagnostic.severity.as_str(), "warning"); + assert_eq!(event.diagnostic.method.as_deref(), Some("session creation")); + assert_eq!( + event.diagnostic.data.as_ref().unwrap()["workspace"], + "C:/workspace/project" + ); + } + + #[test] + fn activation_failure_status_is_workspace_scoped_and_clearable() { + let first = tempfile::tempdir().expect("first workspace"); + let second = tempfile::tempdir().expect("second workspace"); + super::record_configured_plugin_activation_failure( + "session creation", + Some(first.path()), + "Bun executable was not found", + ); + + let first_status = super::configured_plugin_activation_failures(Some(first.path())); + assert_eq!(first_status.len(), 1); + assert!(first_status[0].contains("session creation")); + assert!(super::configured_plugin_activation_failures(Some(second.path())).is_empty()); + + super::clear_configured_plugin_activation_failure(Some(first.path())); + assert!(super::configured_plugin_activation_failures(Some(first.path())).is_empty()); + } } diff --git a/src/crates/assembly/core/src/plugin_host_http.rs b/src/crates/assembly/core/src/plugin_host_http.rs index fa8455d39a..f7588f0d4f 100644 --- a/src/crates/assembly/core/src/plugin_host_http.rs +++ b/src/crates/assembly/core/src/plugin_host_http.rs @@ -1,526 +1,125 @@ +use async_trait::async_trait; use bitfun_opencode_plugin_host::{ - json_error_body, match_http_route, read_host_stream, BackendHttpRequest, BackendHttpResponse, - HostStreamReadError, HttpRouteError, OpenCodeClientRoute, PluginHostClient, - PluginHostStreamRegistry, RpcHandlerError, StreamCancelParams, StreamReadParams, - StreamRegistryError, MAX_HTTP_BODY_BYTES, + BackendDiagnosticError, BackendDiagnosticEvent, BackendRouteFailure, BackendRouteRequest, + OpenCodeBackendHandler, PluginHostBackendBridge, PluginHostClient, }; use serde::de::DeserializeOwned; use serde_json::{json, Value}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::{Notify, OnceCell}; +use std::sync::{Arc, OnceLock, RwLock}; -const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -const HTTP_DRAIN_TIMEOUT: Duration = Duration::from_secs(3); +static PLUGIN_HOST_BACKEND_BRIDGE: OnceLock>>> = + OnceLock::new(); -static PLUGIN_HOST_BACKEND_BRIDGE: OnceCell> = OnceCell::const_new(); +struct CoreOpenCodeBackend; -pub(crate) struct PluginHostBackendBridge { - client: PluginHostClient, - streams: PluginHostStreamRegistry, - draining: AtomicBool, - active_requests: AtomicUsize, - requests_drained: Notify, -} - -struct ActiveRequest<'a> { - bridge: &'a PluginHostBackendBridge, -} - -impl Drop for ActiveRequest<'_> { - fn drop(&mut self) { - if self.bridge.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { - self.bridge.requests_drained.notify_waiters(); - } - } -} - -#[derive(Debug)] -pub(crate) struct RouteFailure { - pub(crate) status: u16, - pub(crate) code: &'static str, - pub(crate) message: String, -} - -impl RouteFailure { - pub(crate) fn bad_request(message: impl Into) -> Self { - Self::new(400, "invalid_request", message) - } - - pub(crate) fn forbidden(message: impl Into) -> Self { - Self::new(403, "instance_scope_denied", message) - } - - pub(crate) fn not_found(message: impl Into) -> Self { - Self::new(404, "not_found", message) - } - - pub(crate) fn unsupported(message: impl Into) -> Self { - Self::new(501, "unsupported_capability", message) - } - - pub(crate) fn backend(message: impl Into) -> Self { - Self::new(502, "backend_failure", message) - } - - pub(crate) fn unavailable(message: impl Into) -> Self { - Self::new(503, "backend_unavailable", message) - } - - fn new(status: u16, code: &'static str, message: impl Into) -> Self { - Self { - status, - code, - message: message.into(), - } - } -} - -impl PluginHostBackendBridge { - fn new(client: PluginHostClient) -> Self { - Self { - client, - streams: PluginHostStreamRegistry::default(), - draining: AtomicBool::new(false), - active_requests: AtomicUsize::new(0), - requests_drained: Notify::new(), - } - } - - fn admit(&self) -> Option> { - if self.draining.load(Ordering::Acquire) { - return None; - } - self.active_requests.fetch_add(1, Ordering::AcqRel); - if self.draining.load(Ordering::Acquire) { - if self.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { - self.requests_drained.notify_waiters(); - } - return None; - } - Some(ActiveRequest { bridge: self }) - } - - async fn handle_http(self: Arc, params: Value) -> Result { - let request: BackendHttpRequest = serde_json::from_value(params) - .map_err(|error| invalid_rpc_params("backend.http.request", error))?; - if request.instance_id.trim().is_empty() - || request.instance_id.len() > 256 - || request.request_id.trim().is_empty() - || request.request_id.len() > 256 - || request.method.len() > 16 - || request.headers.len() > 64 - { - return Err(RpcHandlerError::new( - -32602, - "Invalid request identity, method, or header count for backend.http.request", - )); - } - let started_at = Instant::now(); - let path = request.path.clone(); - let method = request.method.clone(); - let instance_id = request.instance_id.clone(); - let request_id = request.request_id.clone(); - let Some(_active) = self.admit() else { - return self - .http_error( - &instance_id, - 503, - "host_draining", - "Plugin host is shutting down", - &path, - ) - .await; - }; - - let route_match = match match_http_route(&method, &path) { - Ok(route_match) => route_match, - Err(HttpRouteError::InvalidPath) => { - return self - .http_error( - &instance_id, - 400, - "invalid_request", - "Request path is invalid", - &path, - ) - .await - } - Err(HttpRouteError::NotFound) => { - return self - .http_error( - &instance_id, - 404, - "route_not_found", - "OpenCode client route was not found", - &path, - ) - .await - } - Err(HttpRouteError::MethodNotAllowed) => { - return self - .http_error( - &instance_id, - 405, - "method_not_allowed", - "HTTP method is not allowed for this route", - &path, - ) - .await - } - }; - let operation = route_match.route.operation(); - let context = match crate::plugin_host::plugin_host_instance_by_id(&instance_id).await { - Some(context) => context, - None => { - return self - .http_error( - &instance_id, - 404, - "instance_not_found", - "Plugin host instance was not found", - &path, - ) - .await - } - }; +#[async_trait] +impl OpenCodeBackendHandler for CoreOpenCodeBackend { + async fn handle_route( + &self, + request: BackendRouteRequest, + ) -> Result { + let context = crate::plugin_host::plugin_host_instance_by_id(&request.instance_id) + .await + .ok_or_else(|| BackendRouteFailure::not_found("Plugin host instance was not found"))?; if !context.is_ready() { log::debug!( - "Plugin client request admitted during activation: instance_id={}, request_id={}, operation={}", - instance_id, - request_id, - operation + "Plugin client request admitted during activation: instance_id={}, operation={}", + request.instance_id, + request.route.operation() ); } - if let Some(directory) = route_match.query_first("directory") { - if !crate::plugin_host::instance_directories_equal(directory, &context.directory) { - return self - .http_error( - &instance_id, - 403, - "instance_scope_denied", - "Request directory does not belong to this plugin instance", - &path, - ) - .await; - } + if let Some(directory) = request.query_first("directory") { + validate_instance_directory(directory, &context.directory)?; } if let Some(directory) = request.headers.iter().find_map(|(name, value)| { name.eq_ignore_ascii_case("x-opencode-directory") .then_some(value.as_str()) }) { - if !crate::plugin_host::instance_directories_equal(directory, &context.directory) { - return self - .http_error( - &instance_id, - 403, - "instance_scope_denied", - "Request directory does not belong to this plugin instance", - &path, - ) - .await; - } + validate_instance_directory(directory, &context.directory)?; } - let body = match request.body.as_ref() { - Some(descriptor) => match read_host_stream( - &self.client, - &instance_id, - descriptor, - MAX_HTTP_BODY_BYTES, - HTTP_REQUEST_TIMEOUT, - ) - .await - { - Ok(body) => body, - Err(HostStreamReadError::BodyTooLarge) => { - return self - .http_error( - &instance_id, - 413, - "request_too_large", - "Request body exceeds the configured limit", - &path, - ) - .await - } - Err(error) => { - return self - .http_error( - &instance_id, - 502, - "backend_failure", - &format!("Failed to read request body: {error}"), - &path, - ) - .await - } - }, - None => Vec::new(), - }; - - let outcome = tokio::time::timeout( - HTTP_REQUEST_TIMEOUT, - dispatch_route(&context, route_match.route, &route_match.query, &body), - ) - .await; - let response = match outcome { - Ok(Ok(value)) => self.json_response(&instance_id, 200, value).await, - Ok(Err(error)) => { - self.http_error( - &instance_id, - error.status, - error.code, - &error.message, - &path, - ) - .await - } - Err(_) => { - self.http_error( - &instance_id, - 504, - "backend_timeout", - "Backend route timed out", - &path, - ) - .await - } - }; - let status = response - .as_ref() - .ok() - .and_then(|value| value.get("status")) - .and_then(Value::as_u64) - .unwrap_or(500); - log::info!( - "Plugin client request completed: instance_id={}, request_id={}, method={}, path={}, status={}, duration_ms={}, route_status=A, operation={}", - instance_id, - request_id, - method, - path, - status, - u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), - operation - ); - response - } - - async fn json_response( - &self, - instance_id: &str, - status: u16, - value: Value, - ) -> Result { - let bytes = serde_json::to_vec(&value).map_err(|error| { - RpcHandlerError::new( - -32603, - format!("Failed to serialize HTTP response: {error}"), - ) - })?; - self.bytes_response(instance_id, status, "application/json", bytes) - .await - } - - async fn http_error( - &self, - instance_id: &str, - status: u16, - code: &str, - message: &str, - route: &str, - ) -> Result { - self.bytes_response( - instance_id, - status, - "application/json", - json_error_body(code, message, route), + crate::plugin_host_http_routes::dispatch_route( + &context, + request.route, + &request.query, + &request.body, ) .await } - async fn bytes_response( + async fn publish_diagnostic( &self, - instance_id: &str, - status: u16, - content_type: &str, - bytes: Vec, - ) -> Result { - let body = self - .streams - .add(instance_id, bytes) - .await - .map_err(stream_rpc_error)?; - serde_json::to_value(BackendHttpResponse { - status, - status_text: None, - headers: vec![("content-type".to_string(), content_type.to_string())], - body: Some(body), - }) - .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) - } - - pub(crate) async fn begin_draining(&self) { - self.draining.store(true, Ordering::Release); - let active_requests = self.active_requests.load(Ordering::Acquire); - let active_streams = self.streams.active_count().await; - log::info!( - "Plugin client bridge draining started: active_requests={}, active_streams={}", - active_requests, - active_streams - ); - let wait = async { - loop { - let notified = self.requests_drained.notified(); - if self.active_requests.load(Ordering::Acquire) == 0 { - return; - } - notified.await; - } - }; - if tokio::time::timeout(HTTP_DRAIN_TIMEOUT, wait) - .await - .is_err() - { - log::warn!( - "Plugin client bridge request drain timed out: active_requests={}", - self.active_requests.load(Ordering::Acquire) - ); - } - let streams_drained = self.streams.wait_until_empty(HTTP_DRAIN_TIMEOUT).await; - if !streams_drained { - log::warn!( - "Plugin client bridge response stream drain timed out: active_streams={}", - self.streams.active_count().await - ); - } - let cancelled = self.streams.cancel_all().await; - log::info!( - "Plugin client bridge draining completed: active_requests={}, cancelled_streams={}", - self.active_requests.load(Ordering::Acquire), - cancelled - ); + event: BackendDiagnosticEvent, + ) -> Result<(), BackendDiagnosticError> { + crate::plugin_host::publish_plugin_host_diagnostic(event).await } +} - pub(crate) async fn cancel_instance_streams(&self, instance_id: &str) { - let cancelled = self.streams.cancel_instance(instance_id).await; - if cancelled > 0 { - log::debug!( - "Plugin client response streams cancelled: instance_id={}, stream_count={}", - instance_id, - cancelled - ); - } +fn validate_instance_directory( + requested: &str, + owned: &std::path::Path, +) -> Result<(), BackendRouteFailure> { + if crate::plugin_host::instance_directories_equal(requested, owned) { + Ok(()) + } else { + Err(BackendRouteFailure::forbidden( + "Request directory does not belong to this plugin instance", + )) } } pub(crate) async fn register_plugin_host_backend_handlers( client: PluginHostClient, ) -> crate::BitFunResult> { - let bridge = Arc::new(PluginHostBackendBridge::new(client.clone())); - let http_bridge = bridge.clone(); - client - .register_handler("backend.http.request", move |params| { - let bridge = http_bridge.clone(); - async move { bridge.handle_http(params).await } - }) - .await - .map_err(plugin_host_handler_error)?; - let read_bridge = bridge.clone(); - client - .register_handler("backend.stream.read", move |params| { - let bridge = read_bridge.clone(); - async move { - let params: StreamReadParams = serde_json::from_value(params) - .map_err(|error| invalid_rpc_params("backend.stream.read", error))?; - serde_json::to_value( - bridge - .streams - .read(params) - .await - .map_err(stream_rpc_error)?, - ) - .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) - } - }) - .await - .map_err(plugin_host_handler_error)?; - let cancel_bridge = bridge.clone(); - client - .register_handler("backend.stream.cancel", move |params| { - let bridge = cancel_bridge.clone(); - async move { - let params: StreamCancelParams = serde_json::from_value(params) - .map_err(|error| invalid_rpc_params("backend.stream.cancel", error))?; - serde_json::to_value( - bridge - .streams - .cancel(params) - .await - .map_err(stream_rpc_error)?, - ) - .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) - } - }) - .await - .map_err(plugin_host_handler_error)?; - PLUGIN_HOST_BACKEND_BRIDGE - .set(bridge.clone()) - .map_err(|_| { - crate::BitFunError::ProcessError( - "Plugin host backend bridge is already initialized".to_string(), - ) - })?; + let previous = PLUGIN_HOST_BACKEND_BRIDGE + .get_or_init(|| RwLock::new(None)) + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(previous) = previous { + if !previous.begin_draining().await { + return Err(crate::BitFunError::ProcessError( + "Previous plugin Host backend requests did not stop before replacement".to_string(), + )); + } + } + let bridge = bitfun_opencode_plugin_host::register_backend_handlers( + client, + Arc::new(CoreOpenCodeBackend), + ) + .await + .map_err(|error| { + crate::BitFunError::ProcessError(format!( + "Failed to register plugin host backend handler: {error}" + )) + })?; + *PLUGIN_HOST_BACKEND_BRIDGE + .get_or_init(|| RwLock::new(None)) + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(bridge.clone()); Ok(bridge) } pub(crate) fn plugin_host_backend_bridge() -> Option> { - PLUGIN_HOST_BACKEND_BRIDGE.get().cloned() -} - -fn invalid_rpc_params(method: &str, error: serde_json::Error) -> RpcHandlerError { - RpcHandlerError::new(-32602, format!("Invalid parameters for {method}: {error}")) -} - -fn plugin_host_handler_error( - error: bitfun_opencode_plugin_host::PluginHostError, -) -> crate::BitFunError { - crate::BitFunError::ProcessError(format!( - "Failed to register plugin host backend handler: {error}" - )) -} - -fn stream_rpc_error(error: StreamRegistryError) -> RpcHandlerError { - match error { - StreamRegistryError::InstanceMismatch => RpcHandlerError::new(-32003, error.to_string()), - StreamRegistryError::InvalidMaxBytes => RpcHandlerError::new(-32602, error.to_string()), - StreamRegistryError::Capacity | StreamRegistryError::BodyTooLarge => { - RpcHandlerError::new(-32000, error.to_string()) - } - } + PLUGIN_HOST_BACKEND_BRIDGE + .get()? + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() } -fn parse_body(body: &[u8]) -> Result { +fn parse_body(body: &[u8]) -> Result { if body.is_empty() { serde_json::from_value(json!({})) - .map_err(|error| RouteFailure::bad_request(error.to_string())) + .map_err(|error| BackendRouteFailure::bad_request(error.to_string())) } else { - serde_json::from_slice(body).map_err(|error| RouteFailure::bad_request(error.to_string())) + serde_json::from_slice(body) + .map_err(|error| BackendRouteFailure::bad_request(error.to_string())) } } -async fn dispatch_route( - context: &crate::plugin_host::PluginHostInstance, - route: OpenCodeClientRoute, - query: &std::collections::HashMap>, - body: &[u8], -) -> Result { - crate::plugin_host_http_routes::dispatch_route(context, route, query, body).await -} - -pub(crate) fn body_as(body: &[u8]) -> Result { +pub(crate) fn body_as(body: &[u8]) -> Result { parse_body(body) } -pub(crate) type RouteResult = Result; -pub(crate) use RouteFailure as Failure; +pub(crate) type RouteResult = Result; +pub(crate) type Failure = BackendRouteFailure; diff --git a/src/crates/assembly/core/src/plugin_host_http_routes.rs b/src/crates/assembly/core/src/plugin_host_http_routes.rs index 8baf9d565e..9e52aa26bc 100644 --- a/src/crates/assembly/core/src/plugin_host_http_routes.rs +++ b/src/crates/assembly/core/src/plugin_host_http_routes.rs @@ -541,8 +541,8 @@ include!("plugin_host_http_routes_impl.rs"); #[cfg(test)] mod tests { use super::{ - app_log, assistant_parts, file_list, file_read, find_files, find_text, parse_pty_shell, - lsp_status, project_list, project_value, provider_projection, pty_create, pty_value, + app_log, assistant_parts, file_list, file_read, find_files, find_text, lsp_status, + parse_pty_shell, project_list, project_value, provider_projection, pty_create, pty_value, resolve_scoped_path, search_line_offsets, session_create, session_update, tool_list, }; use crate::plugin_host::PluginHostInstance; @@ -562,8 +562,17 @@ mod tests { project_id: project_id.to_string(), created_at_ms: 1, instance_id: instance_id.to_string(), - open_result: json!({}), + host_generation: 1, + generation_key: "generation-test".to_string(), + revision: "revision-test".to_string(), + registration_batch: None, ready: true, + hook_commit_token: None, + transformed_config_health_snapshot: None, + diagnostic_health_snapshot: Vec::new(), + tool_names: Vec::new(), + agent_runtime_keys: Vec::new(), + retirement_scheduled: false, } } @@ -587,9 +596,7 @@ mod tests { .await .expect_err("explore keeps LSP retired"); - assert_eq!(failure.status, 501); - assert_eq!(failure.code, "unsupported_capability"); - assert!(failure.message.contains("retired")); + assert!(failure.message().contains("retired")); } #[test] diff --git a/src/crates/assembly/core/src/plugin_runtime.rs b/src/crates/assembly/core/src/plugin_runtime.rs index af1bf8e1d4..5ddf1a4b19 100644 --- a/src/crates/assembly/core/src/plugin_runtime.rs +++ b/src/crates/assembly/core/src/plugin_runtime.rs @@ -27,6 +27,14 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[cfg(feature = "opencode-plugin-host")] +pub(crate) fn opencode_config_snapshot( + workspace: &Path, +) -> Result { + bitfun_opencode_adapter::load_opencode_config_snapshot(workspace) + .map_err(|error| error.to_string()) +} + const PREVIEW_PROJECT_ID: &str = "managed-plugin-preview"; const PREVIEW_WORKSPACE_ID: &str = "managed-plugin-preview"; const DSH_MANIFEST_ADAPTER_ID: &str = "dsh_compatible"; diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 29b23a5be7..6f8f3bd0ee 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -9,8 +9,8 @@ use bitfun_agent_runtime::sdk::{ AgentEventSource, AgentInteractionResponsePort, AgentModeCatalogEntry, AgentModeCatalogPort, AgentModeCatalogQuery, AgentRuntime, AgentRuntimeBuilder, AgentSessionCompactionPort, AgentSessionForkPort, AgentSessionLineagePort, AgentSessionModePort, AgentSessionModelPort, - AgentSessionRestorePort, AgentSessionRevertPort, AgentSessionUsagePort, - AgentTurnSettlementPort, RuntimeError, + AgentSessionRestorePort, AgentSessionRestoreRequest, AgentSessionRestoreResult, + AgentSessionRevertPort, AgentSessionUsagePort, AgentTurnSettlementPort, RuntimeError, }; #[cfg(feature = "remote-connect")] use bitfun_agent_runtime::sdk::{ @@ -20,18 +20,20 @@ use bitfun_agent_runtime::sdk::{ use bitfun_events::AgenticEvent; use bitfun_runtime_ports::{ AgentDialogTurnPort, AgentDialogTurnRequest, AgentLifecycleDeliveryPort, - AgentLocalCommandTurnPort, AgentSessionClosePort, AgentSessionManagementPort, - AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, - AgentSessionRollbackToTurnRequest, AgentSubmissionPort, AgentThreadGoalManagementPort, - AgentTurnCancellationPort, AgentUserShellCommandPort, AgentWorkspaceReferencePort, - SessionStoragePathRequest, SessionStorePort, + AgentLocalCommandTurnPort, AgentSessionClosePort, AgentSessionCreateRequest, + AgentSessionCreateResult, AgentSessionManagementPort, AgentSessionRevertRequest, + AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, AgentSessionRollbackToTurnRequest, + AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, + AgentThreadGoalManagementPort, AgentTurnCancellationPort, AgentUserShellCommandPort, + AgentWorkspaceReferencePort, PortError, PortErrorKind, PortResult, SessionStoragePathRequest, + SessionStorePort, }; #[cfg(feature = "remote-connect")] use bitfun_runtime_ports::{ - AgentInputAttachment, AgentSessionCreateRequest, AgentSubmissionSource, - AgentTurnCancellationRequest, PermissionPolicyPreset, RemoteControlStatePort, - RemoteControlStateRequest, RemoteControlStateSnapshot, RemoteSessionWorkspaceIdentity, - RuntimeServiceCapability, RuntimeServicePort, ToolPermissionConfig, + AgentInputAttachment, AgentSubmissionSource, AgentTurnCancellationRequest, + PermissionPolicyPreset, RemoteControlStatePort, RemoteControlStateRequest, + RemoteControlStateSnapshot, RemoteSessionWorkspaceIdentity, RuntimeServiceCapability, + RuntimeServicePort, ToolPermissionConfig, }; #[cfg(feature = "remote-connect")] use bitfun_services_integrations::remote_connect::{ @@ -88,6 +90,283 @@ use crate::service::config::types::{AIConfig, GlobalConfig, ModelCapability}; #[cfg(feature = "remote-connect")] use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; +#[cfg(feature = "opencode-plugin-host")] +#[derive(Clone)] +struct ConfiguredPluginSubmissionPort { + inner: Arc, + coordinator: Arc, +} + +#[cfg(feature = "opencode-plugin-host")] +impl ConfiguredPluginSubmissionPort { + async fn try_ensure_workspace(request: &AgentSessionCreateRequest) -> PortResult<()> { + let Some(execution_root) = configured_plugin_execution_root(request)? else { + return Ok(()); + }; + crate::plugin_host::ensure_configured_plugin_instance( + crate::plugin_host::PluginHostLaunchPolicy::Enabled, + execution_root.clone(), + execution_root, + request.workspace_id.clone(), + ) + .await + .map(|_| ()) + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string())) + } + + async fn ensure_workspace(request: &AgentSessionCreateRequest) { + if let Err(error) = Self::try_ensure_workspace(request).await { + let workspace = request + .execution_target + .as_ref() + .map(|target| Path::new(&target.root_path)) + .or_else(|| request.workspace_path.as_deref().map(Path::new)); + crate::plugin_host::report_configured_plugin_activation_failure( + "session creation", + workspace, + error, + ) + .await; + } + } + + async fn try_ensure_session(&self, session_id: &str) -> PortResult<()> { + let Some(session) = self + .coordinator + .get_session_manager() + .get_session(session_id) + else { + return Ok(()); + }; + let Some(execution_root) = configured_plugin_root_from_session_facts( + session.config.workspace_path.as_deref(), + session.config.execution_target.as_ref(), + session.config.remote_connection_id.as_deref(), + session.config.remote_ssh_host.as_deref(), + )? + else { + return Ok(()); + }; + crate::plugin_host::ensure_configured_plugin_instance( + crate::plugin_host::PluginHostLaunchPolicy::Enabled, + execution_root.clone(), + execution_root, + session.config.workspace_id.clone(), + ) + .await + .map(|_| ()) + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string())) + } + + async fn ensure_session(&self, session_id: &str) { + if let Err(error) = self.try_ensure_session(session_id).await { + crate::plugin_host::report_configured_plugin_activation_failure( + "existing session", + None, + error, + ) + .await; + } + } +} + +#[cfg(feature = "opencode-plugin-host")] +fn configured_plugin_execution_root( + request: &AgentSessionCreateRequest, +) -> PortResult> { + configured_plugin_root_from_session_facts( + request.workspace_path.as_deref(), + request.execution_target.as_ref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) +} + +#[cfg(feature = "opencode-plugin-host")] +fn configured_plugin_root_from_session_facts( + workspace_path: Option<&str>, + execution_target: Option<&bitfun_core_types::SessionExecutionTarget>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> PortResult> { + if remote_connection_id.is_some() || remote_ssh_host.is_some() { + return Ok(None); + } + execution_target + .map(|target| target.root_path.as_str()) + .or(workspace_path) + .map(std::path::PathBuf::from) + .map(Some) + .ok_or_else(|| { + PortError::new( + PortErrorKind::InvalidRequest, + "workspace_path is required to initialize configured plugins", + ) + }) +} + +#[cfg(feature = "opencode-plugin-host")] +#[async_trait::async_trait] +impl AgentSubmissionPort for ConfiguredPluginSubmissionPort { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + Self::ensure_workspace(&request).await; + self.inner.create_session(request).await + } + + async fn create_session_with_id( + &self, + session_id: String, + request: AgentSessionCreateRequest, + ) -> PortResult { + Self::ensure_workspace(&request).await; + self.inner.create_session_with_id(session_id, request).await + } + + async fn create_transient_session_with_id( + &self, + session_id: String, + request: AgentSessionCreateRequest, + ) -> PortResult { + Self::ensure_workspace(&request).await; + self.inner + .create_transient_session_with_id(session_id, request) + .await + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + // Every existing-session turn is a recovery trigger. The ensure call + // is idempotent while the Host is healthy and republishes the same + // workspace generation after a process loss before execution resumes. + self.ensure_session(&request.session_id).await; + self.inner.submit_message(request).await + } + + async fn resolve_session_agent_type(&self, session_id: &str) -> PortResult> { + self.inner.resolve_session_agent_type(session_id).await + } +} + +fn configured_plugin_submission_port( + coordinator: Arc, +) -> Arc { + #[cfg(feature = "opencode-plugin-host")] + { + let inner: Arc = coordinator.clone(); + Arc::new(ConfiguredPluginSubmissionPort { inner, coordinator }) + } + #[cfg(not(feature = "opencode-plugin-host"))] + { + coordinator + } +} + +#[cfg(feature = "opencode-plugin-host")] +#[derive(Clone)] +struct ConfiguredPluginSessionRestorePort { + inner: Arc, + submission: ConfiguredPluginSubmissionPort, +} + +#[cfg(feature = "opencode-plugin-host")] +#[async_trait::async_trait] +impl AgentSessionRestorePort for ConfiguredPluginSessionRestorePort { + async fn restore_session( + &self, + request: AgentSessionRestoreRequest, + ) -> PortResult { + let restored = self.inner.restore_session(request).await?; + // The restored Session owns the authoritative execution target, + // including managed worktrees. Do not publish a plugin generation for + // the storage-path hint before that target has been reconstructed. + self.submission + .ensure_session(&restored.session.session_id) + .await; + Ok(restored) + } +} + +fn configured_plugin_session_restore_port( + coordinator: Arc, +) -> Arc { + #[cfg(feature = "opencode-plugin-host")] + { + let inner: Arc = coordinator.clone(); + let submission_inner: Arc = coordinator.clone(); + Arc::new(ConfiguredPluginSessionRestorePort { + inner, + submission: ConfiguredPluginSubmissionPort { + inner: submission_inner, + coordinator, + }, + }) + } + #[cfg(not(feature = "opencode-plugin-host"))] + { + coordinator + } +} + +#[cfg(feature = "opencode-plugin-host")] +struct ConfiguredPluginDialogTurnPort { + inner: Arc, + submission: ConfiguredPluginSubmissionPort, +} + +#[cfg(feature = "opencode-plugin-host")] +#[async_trait::async_trait] +impl AgentDialogTurnPort for ConfiguredPluginDialogTurnPort { + async fn submit_dialog_turn( + &self, + request: AgentDialogTurnRequest, + ) -> PortResult { + self.submission.ensure_session(&request.session_id).await; + self.inner.submit_dialog_turn(request).await + } + + async fn steer_dialog_turn( + &self, + request: bitfun_runtime_ports::AgentDialogSteerRequest, + ) -> PortResult { + self.inner.steer_dialog_turn(request).await + } + + async fn recover_interrupted_turn( + &self, + request: bitfun_runtime_ports::AgentDialogTurnRecoveryRequest, + ) -> PortResult { + self.submission.ensure_session(&request.session_id).await; + self.inner.recover_interrupted_turn(request).await + } +} + +fn configured_plugin_dialog_turn_port( + coordinator: Arc, + inner: Arc, +) -> Arc { + #[cfg(feature = "opencode-plugin-host")] + { + let submission_inner: Arc = coordinator.clone(); + Arc::new(ConfiguredPluginDialogTurnPort { + inner, + submission: ConfiguredPluginSubmissionPort { + inner: submission_inner, + coordinator, + }, + }) + } + #[cfg(not(feature = "opencode-plugin-host"))] + { + let _ = coordinator; + inner + } +} + #[cfg(feature = "remote-connect")] fn current_workspace_path() -> Option { crate::service::workspace::get_global_workspace_service() @@ -481,6 +760,7 @@ fn core_agent_runtime_builder( thread_goal_management: Arc, cancellation: Arc, interaction_response: Arc, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, ) -> Result { let agent_registry: Arc = crate::agentic::agents::get_agent_registry(); @@ -500,6 +780,7 @@ fn core_agent_runtime_builder( .with_cancellation_port(cancellation) .with_interaction_response_port(interaction_response) .with_permission_request_manager(crate::product_runtime::core_permission_request_manager()?) + .with_hook_registry(hook_registry) .with_agent_registry(agent_registry) .with_mode_catalog(mode_catalog)) } @@ -543,6 +824,7 @@ impl AgentModeCatalogPort for CoreAgentModeCatalogPort { .into_iter() .map(|mode| AgentModeCatalogEntry { id: mode.id, + route_key: mode.key, description: mode.description, model_id: mode.model, is_external: mode.source == crate::agentic::agents::AgentSource::External, @@ -1476,12 +1758,12 @@ impl CoreServiceAgentRuntime { pub(crate) fn agent_runtime( coordinator: Arc, ) -> Result { - let submission: Arc = coordinator.clone(); + let submission = configured_plugin_submission_port(coordinator.clone()); let session_management: Arc = coordinator.clone(); let workspace_references: Arc = coordinator.clone(); let session_mode: Arc = coordinator.clone(); let session_model: Arc = coordinator.clone(); - let session_restore: Arc = coordinator.clone(); + let session_restore = configured_plugin_session_restore_port(coordinator.clone()); let local_command_turn: Arc = coordinator.clone(); let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = @@ -1489,6 +1771,7 @@ impl CoreServiceAgentRuntime { let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; core_agent_runtime_builder( submission, @@ -1504,6 +1787,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .build() .map_err(|error| error.to_string()) @@ -1513,7 +1797,7 @@ impl CoreServiceAgentRuntime { coordinator: Arc, scheduler: Arc, ) -> Result { - let submission: Arc = coordinator.clone(); + let submission = configured_plugin_submission_port(coordinator.clone()); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let workspace_references: Arc = coordinator.clone(); @@ -1521,7 +1805,7 @@ impl CoreServiceAgentRuntime { let session_revert = scheduled_session_revert_port(coordinator.clone(), scheduler.clone()); let session_mode: Arc = coordinator.clone(); let session_model: Arc = coordinator.clone(); - let session_restore: Arc = coordinator.clone(); + let session_restore = configured_plugin_session_restore_port(coordinator.clone()); let local_command_turn: Arc = coordinator.clone(); let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = @@ -1529,8 +1813,10 @@ impl CoreServiceAgentRuntime { let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); - let interaction_response: Arc = coordinator; - let dialog_turn: Arc = scheduler.clone(); + let hook_registry = coordinator.hook_registry().clone(); + let interaction_response: Arc = coordinator.clone(); + let dialog_turn = + configured_plugin_dialog_turn_port(coordinator.clone(), scheduler.clone()); let lifecycle_delivery: Arc = scheduler; core_agent_runtime_builder( submission, @@ -1546,6 +1832,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_close_port(session_close) .with_session_revert_port(session_revert) @@ -1559,14 +1846,14 @@ impl CoreServiceAgentRuntime { coordinator: Arc, scheduler: Arc, ) -> Result { - let submission: Arc = coordinator.clone(); + let submission = configured_plugin_submission_port(coordinator.clone()); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let workspace_references: Arc = coordinator.clone(); let session_revert = scheduled_session_revert_port(coordinator.clone(), scheduler.clone()); let session_mode: Arc = coordinator.clone(); let session_model: Arc = coordinator.clone(); - let session_restore: Arc = coordinator.clone(); + let session_restore = configured_plugin_session_restore_port(coordinator.clone()); let local_command_turn: Arc = coordinator.clone(); let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = @@ -1574,6 +1861,7 @@ impl CoreServiceAgentRuntime { let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; let lifecycle_delivery: Arc = scheduler; core_agent_runtime_builder( @@ -1590,6 +1878,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_revert_port(session_revert) .with_lifecycle_delivery_port(lifecycle_delivery) @@ -1606,7 +1895,7 @@ impl CoreServiceAgentRuntime { session_usage: Arc, session_lineage: Arc, ) -> Result { - let submission: Arc = coordinator.clone(); + let submission = configured_plugin_submission_port(coordinator.clone()); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let workspace_references: Arc = coordinator.clone(); @@ -1614,9 +1903,11 @@ impl CoreServiceAgentRuntime { let session_mode: Arc = coordinator.clone(); let session_model: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let session_restore = configured_plugin_session_restore_port(coordinator.clone()); let local_command_turn: Arc = coordinator.clone(); - let interaction_response: Arc = coordinator; - let dialog_turn: Arc = scheduler.clone(); + let interaction_response: Arc = coordinator.clone(); + let dialog_turn = + configured_plugin_dialog_turn_port(coordinator.clone(), scheduler.clone()); let cancellation: Arc = scheduler; AgentRuntimeBuilder::new() @@ -1627,6 +1918,7 @@ impl CoreServiceAgentRuntime { .with_session_mode_port(session_mode) .with_session_model_port(session_model) .with_session_compaction_port(session_compaction) + .with_session_restore_port(session_restore) .with_local_command_turn_port(local_command_turn) .with_dialog_turn_port(dialog_turn) .with_cancellation_port(cancellation) @@ -1645,23 +1937,25 @@ impl CoreServiceAgentRuntime { coordinator: Arc, scheduler: Arc, ) -> Result { - let submission: Arc = coordinator.clone(); + let submission = configured_plugin_submission_port(coordinator.clone()); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let workspace_references: Arc = coordinator.clone(); let session_revert = scheduled_session_revert_port(coordinator.clone(), scheduler.clone()); let session_mode: Arc = coordinator.clone(); let session_model: Arc = coordinator.clone(); - let session_restore: Arc = coordinator.clone(); + let session_restore = configured_plugin_session_restore_port(coordinator.clone()); let local_command_turn: Arc = coordinator.clone(); let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); - let interaction_response: Arc = coordinator; + let hook_registry = coordinator.hook_registry().clone(); + let interaction_response: Arc = coordinator.clone(); let cancellation: Arc = scheduler.clone(); - let dialog_turn: Arc = scheduler.clone(); + let dialog_turn = + configured_plugin_dialog_turn_port(coordinator.clone(), scheduler.clone()); let lifecycle_delivery: Arc = scheduler; core_agent_runtime_builder( submission, @@ -1677,6 +1971,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_revert_port(session_revert) .with_dialog_turn_port(dialog_turn) @@ -1764,7 +2059,8 @@ impl CoreServiceAgentRuntime { session_lineage: Option>, services: bitfun_runtime_services::RuntimeServices, ) -> Result { - let submission: Arc = coordinator.clone(); + let dialog_turn = configured_plugin_dialog_turn_port(coordinator.clone(), dialog_turn); + let submission = configured_plugin_submission_port(coordinator.clone()); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let workspace_references: Arc = coordinator.clone(); @@ -1772,13 +2068,14 @@ impl CoreServiceAgentRuntime { let session_revert = scheduled_session_revert_port(coordinator.clone(), scheduler.clone()); let session_mode: Arc = coordinator.clone(); let session_model: Arc = coordinator.clone(); - let session_restore: Arc = coordinator.clone(); + let session_restore = configured_plugin_session_restore_port(coordinator.clone()); let local_command_turn: Arc = coordinator.clone(); let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let lifecycle_delivery: Arc = scheduler; @@ -1797,6 +2094,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_close_port(session_close) .with_session_revert_port(session_revert) @@ -2618,6 +2916,81 @@ mod tests { }; use crate::BitFunError; + #[cfg(feature = "opencode-plugin-host")] + fn plugin_session_request() -> AgentSessionCreateRequest { + AgentSessionCreateRequest { + session_name: "session".to_string(), + agent_type: "Code".to_string(), + agent_route_key: None, + workspace_path: Some("project".to_string()), + project_workspace_path: None, + execution_target: Some(bitfun_core_types::SessionExecutionTarget::local( + "project-worktree", + )), + workspace_id: Some("workspace-a".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata: serde_json::Map::new(), + } + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn configured_plugins_bind_to_the_session_execution_root() { + let request = plugin_session_request(); + + assert_eq!( + configured_plugin_execution_root(&request).expect("local execution root"), + Some(std::path::PathBuf::from("project-worktree")) + ); + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn configured_plugins_do_not_execute_for_remote_sessions() { + let mut request = plugin_session_request(); + request.remote_connection_id = Some("remote-a".to_string()); + + assert_eq!( + configured_plugin_execution_root(&request).expect("remote session is supported"), + None + ); + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn configured_plugins_recover_from_the_persisted_session_execution_root() { + let target = bitfun_core_types::SessionExecutionTarget::local("restored-worktree"); + + assert_eq!( + configured_plugin_root_from_session_facts(Some("project"), Some(&target), None, None,) + .expect("restored local execution root"), + Some(std::path::PathBuf::from("restored-worktree")) + ); + assert_eq!( + configured_plugin_root_from_session_facts( + Some("/remote/project"), + None, + Some("remote-a"), + None, + ) + .expect("remote session remains outside the local Host"), + None + ); + } + + #[cfg(feature = "opencode-plugin-host")] + #[tokio::test] + async fn configured_plugin_failure_does_not_block_native_session_creation() { + let mut request = plugin_session_request(); + request.workspace_path = None; + request.execution_target = None; + + let outcome: () = ConfiguredPluginSubmissionPort::ensure_workspace(&request).await; + assert_eq!(outcome, ()); + } + #[test] fn session_close_preserves_writer_conflicts() { let error = map_session_close_error(BitFunError::SessionInUse { @@ -2826,6 +3199,36 @@ mod tests { assert!(builder .contains("let session_mode: Arc = coordinator.clone();")); assert!(builder.contains(".with_session_mode_port(session_mode)")); + assert!(builder.contains( + "let session_restore = configured_plugin_session_restore_port(coordinator.clone());" + )); + assert!(builder.contains(".with_session_restore_port(session_restore)")); + assert!(builder.contains("configured_plugin_dialog_turn_port(")); + } + + #[cfg(feature = "opencode-plugin-host")] + #[test] + fn configured_dialog_turn_port_recovers_plugins_before_session_execution() { + let source = include_str!("service_agent_runtime.rs"); + let body = source + .split("impl AgentDialogTurnPort for ConfiguredPluginDialogTurnPort") + .nth(1) + .and_then(|source| source.split("fn configured_plugin_dialog_turn_port").next()) + .expect("configured plugin dialog turn port"); + + for method in ["submit_dialog_turn", "recover_interrupted_turn"] { + let method_body = body + .split(&format!("async fn {method}")) + .nth(1) + .expect("dialog method implementation"); + let ensure = method_body + .find("self.submission.ensure_session") + .expect("plugin recovery gate"); + let delegate = method_body + .find(&format!("self.inner.{method}")) + .expect("dialog delegate"); + assert!(ensure < delegate, "{method} must recover plugins first"); + } } #[test] diff --git a/src/crates/contracts/runtime-ports/AGENTS.md b/src/crates/contracts/runtime-ports/AGENTS.md index bbcec6650a..78ddd386e3 100644 --- a/src/crates/contracts/runtime-ports/AGENTS.md +++ b/src/crates/contracts/runtime-ports/AGENTS.md @@ -26,6 +26,17 @@ facts. It is an interface crate, not a runtime implementation crate. 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. +- `HookFunctionRuntime` owns only provider-neutral availability, single-worker + start/transform-config/execute-tool/cancel/dispose requests, a complete + plugin-generation registration **notification sink** (not a load response), + a reverse-channel sink (`metadata`/`ask`/`ask_reply`), and typed config/tool + results. One worker owns the ordered plugin set and returns one final config + result for the complete config hook chain. + Plugin activation/trust, process supervision, `server(PluginInput)` to + `Hooks` construction, permission decisions, and UI do not belong here. It + deliberately diverges from `ScriptToolRuntime` (push notifications plus + reverse channel, versus synchronous load/invoke response); do not collapse + the two. - 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/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index 6937fbc7fe..d425282e16 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -25,6 +25,11 @@ name = "script_tool_port_contracts" path = "tests/script_tool_port_contracts.rs" required-features = ["script-tool-runtime"] +[[test]] +name = "hook_function_runtime_contracts" +path = "tests/hook_function_runtime_contracts.rs" +required-features = ["hook-function-runtime"] + [[test]] name = "session_store_contracts" path = "tests/session_store_contracts.rs" @@ -45,6 +50,7 @@ ts-rs = { workspace = true, optional = true } default = [] agent-api = ["dep:bitfun-core-types"] plugin-runtime = [] +hook-function-runtime = [] script-tool-runtime = [] workspace-ports = ["dep:anyhow", "dep:tokio-util"] terminal-port = ["dep:tokio"] diff --git a/src/crates/contracts/runtime-ports/src/agent_api.rs b/src/crates/contracts/runtime-ports/src/agent_api.rs index 2cc0186df0..e9b0d5fec0 100644 --- a/src/crates/contracts/runtime-ports/src/agent_api.rs +++ b/src/crates/contracts/runtime-ports/src/agent_api.rs @@ -10,6 +10,8 @@ pub const OUTPUT_SCHEMA_CONTEXT_KEY: &str = "bitfun_output_schema"; pub struct AgentSessionCreateRequest { pub session_name: String, pub agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_route_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -232,6 +234,8 @@ pub struct AgentSessionModelSelectionUpdateRequest { pub struct AgentSessionModeUpdateRequest { pub session_id: String, pub mode_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_route_key: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -248,6 +252,8 @@ pub struct AgentModeCatalogQuery { #[serde(rename_all = "camelCase")] pub struct AgentModeCatalogEntry { pub id: String, + #[serde(default)] + pub route_key: String, pub description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, diff --git a/src/crates/contracts/runtime-ports/src/hook_function.rs b/src/crates/contracts/runtime-ports/src/hook_function.rs new file mode 100644 index 0000000000..d713ef4ab8 --- /dev/null +++ b/src/crates/contracts/runtime-ports/src/hook_function.rs @@ -0,0 +1,322 @@ +use crate::PortResult; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum HookFunctionAvailability { + Available, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionGeneration { + pub instance_id: String, + pub generation_key: String, + pub revision: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionPluginDeclaration { + pub spec: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_directory: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionPluginIdentity { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub spec: String, + pub entry: String, + pub index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookFunctionContributorOutcome { + Applied, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionConfigContributor { + pub plugin: HookFunctionPluginIdentity, + pub outcome: HookFunctionContributorOutcome, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionConfigContribution { + pub plugin: HookFunctionPluginIdentity, + pub outcome: HookFunctionContributorOutcome, + pub config: Map, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookFunctionDiagnosticSeverity { + Debug, + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionDiagnostic { + pub severity: HookFunctionDiagnosticSeverity, + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookFunctionHookKind { + ToolExecuteBefore, + ToolExecuteAfter, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionToolRegistration { + pub registration_id: String, + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin: Option, + pub description: String, + pub parameters: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionRegistrationBatch { + pub generation: HookFunctionGeneration, + pub config: Map, + pub config_contributors: Vec, + pub config_contributions: Vec, + pub diagnostics: Vec, + pub hooks: Vec, + pub tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionStartRequest { + pub generation: HookFunctionGeneration, + pub project_id: String, + pub project_worktree: String, + pub project_created_at_ms: u64, + pub config: Map, + pub directory: String, + pub worktree: String, + pub plugins: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub configuration_fingerprint: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub expected_content_digests: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_review_digest: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionBeforeRequest { + pub generation: HookFunctionGeneration, + pub tool_name: String, + pub session_id: String, + pub call_id: String, + pub args: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionBeforeResult { + pub args: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionAfterOutput { + pub title: String, + pub output: String, + #[serde(default)] + pub metadata: Map, +} + +pub type HookFunctionAfterResult = HookFunctionAfterOutput; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionAfterRequest { + pub generation: HookFunctionGeneration, + pub tool_name: String, + pub session_id: String, + pub call_id: String, + pub args: Value, + pub output: HookFunctionAfterOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionToolContext { + pub session_id: String, + pub message_id: String, + pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub call_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionToolRequest { + pub generation: HookFunctionGeneration, + pub execution_id: String, + pub registration_id: String, + pub args: Value, + pub context: HookFunctionToolContext, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionToolAttachment { + pub mime: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionToolResult { + pub output: Value, + pub attachments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionReverseMetadata { + pub generation: HookFunctionGeneration, + pub execution_id: String, + pub title: String, + pub metadata: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionReverseAsk { + pub generation: HookFunctionGeneration, + pub execution_id: String, + pub permission: String, + pub patterns: Vec, + pub always: Vec, + pub metadata: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "reply")] +pub enum HookFunctionReverseReply { + Once, + Always, + Reject { + #[serde(default, skip_serializing_if = "Option::is_none")] + feedback: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionCancelRequest { + pub generation: HookFunctionGeneration, + pub execution_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionCancelResult { + pub stopped: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionDisposeRequest { + pub generation: HookFunctionGeneration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookFunctionDisposeResult { + pub closed: bool, +} + +#[async_trait] +pub trait HookFunctionRegistrationSink: Send + Sync { + async fn publish_generation(&self, batch: HookFunctionRegistrationBatch) -> PortResult<()>; +} + +#[async_trait] +pub trait HookFunctionReverseSink: Send + Sync { + async fn metadata(&self, update: HookFunctionReverseMetadata) -> PortResult<()>; + async fn ask(&self, request: HookFunctionReverseAsk) -> PortResult; +} + +#[async_trait] +pub trait HookFunctionRuntime: Send + Sync { + fn availability(&self) -> HookFunctionAvailability; + + async fn start( + &self, + request: HookFunctionStartRequest, + registrations: Arc, + reverse: Arc, + deadline: Duration, + ) -> PortResult; + + async fn transform_tool_before( + &self, + request: HookFunctionBeforeRequest, + deadline: Duration, + ) -> PortResult; + + async fn execute_tool( + &self, + request: HookFunctionToolRequest, + deadline: Duration, + ) -> PortResult; + + async fn transform_tool_after( + &self, + request: HookFunctionAfterRequest, + deadline: Duration, + ) -> PortResult; + + async fn cancel( + &self, + request: HookFunctionCancelRequest, + deadline: Duration, + ) -> PortResult; + + async fn dispose( + &self, + request: HookFunctionDisposeRequest, + deadline: Duration, + ) -> PortResult; +} diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 3e53a8c2eb..3cbda9080e 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -13,6 +13,8 @@ pub use bitfun_core_types::{ WorktreeSummary, }; +#[cfg(feature = "hook-function-runtime")] +mod hook_function; #[cfg(feature = "workspace-ports")] mod local_workspace_snapshot; #[cfg(feature = "permission")] @@ -40,6 +42,8 @@ pub use bitfun_product_domains::tool_permissions::{ PermissionRuleset, PermissionRuntimeCeiling, PermissionRuntimeCeilingValidationError, ResolvedPermissionMode, ResolvedPermissionPolicy, ToolPermissionConfig, }; +#[cfg(feature = "hook-function-runtime")] +pub use hook_function::*; #[cfg(feature = "workspace-ports")] pub use local_workspace_snapshot::{ LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, diff --git a/src/crates/contracts/runtime-ports/tests/hook_function_runtime_contracts.rs b/src/crates/contracts/runtime-ports/tests/hook_function_runtime_contracts.rs new file mode 100644 index 0000000000..82e1c617a1 --- /dev/null +++ b/src/crates/contracts/runtime-ports/tests/hook_function_runtime_contracts.rs @@ -0,0 +1,202 @@ +#![cfg(feature = "hook-function-runtime")] + +use async_trait::async_trait; +use bitfun_runtime_ports::{ + HookFunctionAvailability, HookFunctionBeforeRequest, HookFunctionBeforeResult, + HookFunctionCancelRequest, HookFunctionCancelResult, HookFunctionDisposeRequest, + HookFunctionDisposeResult, HookFunctionGeneration, HookFunctionRegistrationBatch, + HookFunctionRegistrationSink, HookFunctionReverseAsk, HookFunctionReverseReply, + HookFunctionReverseSink, HookFunctionRuntime, HookFunctionStartRequest, + HookFunctionToolContext, HookFunctionToolRequest, HookFunctionToolResult, PortResult, +}; +use serde_json::json; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[derive(Default)] +struct CapturingRegistrationSink { + batches: Mutex>, +} + +#[async_trait] +impl HookFunctionRegistrationSink for CapturingRegistrationSink { + async fn publish_generation(&self, batch: HookFunctionRegistrationBatch) -> PortResult<()> { + self.batches.lock().expect("batch lock").push(batch); + Ok(()) + } +} + +struct RejectingReverseSink; + +#[async_trait] +impl HookFunctionReverseSink for RejectingReverseSink { + async fn metadata( + &self, + _update: bitfun_runtime_ports::HookFunctionReverseMetadata, + ) -> PortResult<()> { + Ok(()) + } + + async fn ask(&self, request: HookFunctionReverseAsk) -> PortResult { + Ok(HookFunctionReverseReply::Reject { + feedback: Some(format!("{} denied", request.permission)), + }) + } +} + +struct ContractRuntime; + +#[async_trait] +impl HookFunctionRuntime for ContractRuntime { + fn availability(&self) -> HookFunctionAvailability { + HookFunctionAvailability::Available + } + + async fn start( + &self, + request: HookFunctionStartRequest, + registrations: Arc, + _reverse: Arc, + _deadline: Duration, + ) -> PortResult { + registrations + .publish_generation(HookFunctionRegistrationBatch { + generation: request.generation.clone(), + config: request.config, + config_contributors: Vec::new(), + config_contributions: Vec::new(), + diagnostics: Vec::new(), + hooks: Vec::new(), + tools: Vec::new(), + }) + .await?; + Ok(request.generation) + } + + async fn transform_tool_before( + &self, + request: HookFunctionBeforeRequest, + _deadline: Duration, + ) -> PortResult { + Ok(HookFunctionBeforeResult { args: request.args }) + } + + async fn execute_tool( + &self, + request: HookFunctionToolRequest, + _deadline: Duration, + ) -> PortResult { + Ok(HookFunctionToolResult { + output: json!({ "received": request.args }), + attachments: Vec::new(), + }) + } + + async fn transform_tool_after( + &self, + request: bitfun_runtime_ports::HookFunctionAfterRequest, + _deadline: Duration, + ) -> PortResult { + Ok(request.output) + } + + async fn cancel( + &self, + _request: HookFunctionCancelRequest, + _deadline: Duration, + ) -> PortResult { + Ok(HookFunctionCancelResult { stopped: true }) + } + + async fn dispose( + &self, + _request: HookFunctionDisposeRequest, + _deadline: Duration, + ) -> PortResult { + Ok(HookFunctionDisposeResult { closed: true }) + } +} + +#[tokio::test] +async fn one_runtime_starts_a_complete_generation_before_invocation() { + let runtime = ContractRuntime; + let generation = HookFunctionGeneration { + instance_id: "instance-a".to_string(), + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + }; + let registrations = Arc::new(CapturingRegistrationSink::default()); + let started = runtime + .start( + HookFunctionStartRequest { + generation: generation.clone(), + project_id: "project-a".to_string(), + project_worktree: "C:/workspace".to_string(), + project_created_at_ms: 42, + config: json!({ "agent": {} }).as_object().unwrap().clone(), + directory: "C:/workspace".to_string(), + worktree: "C:/workspace".to_string(), + plugins: Vec::new(), + configuration_fingerprint: None, + expected_content_digests: BTreeMap::new(), + expected_review_digest: None, + }, + registrations.clone(), + Arc::new(RejectingReverseSink), + Duration::from_secs(1), + ) + .await + .expect("generation starts"); + + assert_eq!(started, generation); + let batches = registrations.batches.lock().expect("batch lock"); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].generation, generation); + assert_eq!(batches[0].config["agent"], json!({})); +} + +#[tokio::test] +async fn typed_before_and_tool_results_preserve_generation_and_payloads() { + let runtime = ContractRuntime; + let generation = HookFunctionGeneration { + instance_id: "instance-a".to_string(), + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + }; + let before = runtime + .transform_tool_before( + HookFunctionBeforeRequest { + generation: generation.clone(), + tool_name: "write".to_string(), + session_id: "session-a".to_string(), + call_id: "call-a".to_string(), + args: json!({ "path": "src/main.rs" }), + }, + Duration::from_secs(1), + ) + .await + .expect("before transform"); + assert_eq!(before.args, json!({ "path": "src/main.rs" })); + + let result = runtime + .execute_tool( + HookFunctionToolRequest { + generation, + execution_id: "execution-a".to_string(), + registration_id: "registration-a".to_string(), + args: before.args, + context: HookFunctionToolContext { + session_id: "session-a".to_string(), + message_id: "message-a".to_string(), + agent: "agent-a".to_string(), + call_id: Some("call-a".to_string()), + }, + }, + Duration::from_secs(1), + ) + .await + .expect("tool result"); + assert_eq!(result.output["received"]["path"], "src/main.rs"); + assert!(result.attachments.is_empty()); +} diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index 2a4a01504d..5306d79f6c 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -20,10 +20,12 @@ default = [] native-hook-settings = ["dep:regex", "dep:serde_json"] native-hook-runtime = [ "native-hook-settings", + "dep:async-trait", "dep:log", "dep:serde", "dep:serde_json", "dep:tokio", + "dep:thiserror", "tokio/io-util", "tokio/macros", "tokio/process", diff --git a/src/crates/execution/agent-runtime/src/native_hooks/call.rs b/src/crates/execution/agent-runtime/src/native_hooks/call.rs new file mode 100644 index 0000000000..f05575e972 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/call.rs @@ -0,0 +1,32 @@ +//! Provider-neutral hook calls shared by command, builtin and function hooks. + +use super::kind::RuntimeHookKind; +use super::payload::AgentHookEventPayload; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct HookCall { + pub kind: RuntimeHookKind, + pub cwd: PathBuf, + pub session_id: Option, + pub turn_id: Option, + pub workspace_root: Option, + pub is_remote: bool, + pub model: Option, + pub bypass_permissions: bool, + pub payload: HookCallPayload, +} + +#[derive(Debug, Clone)] +pub enum HookCallPayload { + Lifecycle(AgentHookEventPayload), + Config(Value), + ToolUse { + name: String, + input: Value, + custom_data: HashMap, + agent_type: Option, + }, +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/engine.rs b/src/crates/execution/agent-runtime/src/native_hooks/engine.rs index 1d406000a3..1726c93096 100644 --- a/src/crates/execution/agent-runtime/src/native_hooks/engine.rs +++ b/src/crates/execution/agent-runtime/src/native_hooks/engine.rs @@ -9,12 +9,18 @@ //! - Exit code 2: the event is blocked; stderr provides the blocking reason. //! - Any other exit code, spawn failure, or timeout: a non-blocking warning. +use super::call::{HookCall, HookCallPayload}; +use super::handler::{HookHandler, HookHandlerResult, PluginHookCall}; +use super::kind::RuntimeHookKind; use super::output::{non_empty, AgentHookOutcome, RawHookOutput}; use super::payload::AgentHookPayload; +use super::registry::{RuntimeHookErrorPolicy, RuntimeHookRegistry}; use super::settings::{AgentHookEvent, AgentHookHandler, AgentHookSettings}; use log::{debug, warn}; +use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::Arc; use std::time::Duration; use tokio::io::AsyncWriteExt; use tokio::process::Command; @@ -27,57 +33,286 @@ pub const MAX_HOOK_MODEL_OUTPUT_BYTES: usize = 10_000; const MAX_CAPTURED_OUTPUT_BYTES: usize = 1024 * 1024; /// Executes configured hooks for agent lifecycle events. -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct AgentHookEngine { - settings: AgentHookSettings, + registry: RuntimeHookRegistry, + settings: Option>, } impl AgentHookEngine { pub fn new(settings: AgentHookSettings) -> Self { - Self { settings } + let registry = RuntimeHookRegistry::default(); + registry + .register_batch(settings.registrations()) + .expect("parsed hook settings must produce valid registrations"); + Self { + registry, + settings: Some(Arc::new(settings)), + } + } + + pub fn with_registry(registry: RuntimeHookRegistry) -> Self { + Self { + registry, + settings: None, + } } pub fn is_empty(&self) -> bool { - self.settings.is_empty() + self.registry.plans().is_empty() } pub fn has_rules(&self, event: AgentHookEvent) -> bool { - self.settings.has_rules(event) + self.has_rules_for_workspace(event, None) + } + + pub fn has_rules_for_workspace( + &self, + event: AgentHookEvent, + workspace_scope: Option<&str>, + ) -> bool { + !self + .registry + .registrations_for_workspace(RuntimeHookKind::Lifecycle(event), workspace_scope) + .is_empty() } pub fn settings(&self) -> &AgentHookSettings { - &self.settings + self.settings + .as_deref() + .expect("engine was constructed from a runtime hook registry") + } + + pub fn registry(&self) -> &RuntimeHookRegistry { + &self.registry } /// Run every matching handler for the payload's event, sequentially in /// configuration order (user layers before project layers), and fold /// their results into one [`AgentHookOutcome`]. pub async fn dispatch(&self, payload: &AgentHookPayload, cwd: &Path) -> AgentHookOutcome { + self.dispatch_for_workspace(payload, cwd, None).await + } + + pub async fn dispatch_for_workspace( + &self, + payload: &AgentHookPayload, + cwd: &Path, + workspace_scope: Option<&str>, + ) -> AgentHookOutcome { let event = payload.event(); let mut outcome = AgentHookOutcome::default(); - let rules = self.settings.rules_for(event); - if rules.is_empty() { + let registrations = self + .registry + .registrations_for_workspace(RuntimeHookKind::Lifecycle(event), workspace_scope); + if registrations.is_empty() { return outcome; } let matcher_value = payload.event.matcher_value(); let payload_json = payload.to_json().to_string(); - 'rules: for rule in rules { - if !rule.matcher.matches(matcher_value) { + let call = lifecycle_call(payload, cwd); + for registration in registrations.iter() { + if !registration.matcher.matches(matcher_value) { continue; } - for handler in &rule.handlers { - outcome.executed_handlers += 1; - let finalized = self - .run_and_apply(event, handler, &payload_json, cwd, &mut outcome) + outcome.executed_handlers += 1; + let finalized = match ®istration.handler { + HookHandler::Command(handler) => { + self.run_and_apply(event, handler, &payload_json, cwd, &mut outcome) + .await + } + HookHandler::Builtin { executor } => { + apply_handler_result(executor.execute(&call).await, &mut outcome) + } + HookHandler::Plugin { + executor, + instance_id, + hook_name, + generation_key, + revision, + .. + } => { + let result = tokio::time::timeout( + Duration::from_millis(registration.plan.timeout_millis()), + executor.execute(PluginHookCall { + instance_id: instance_id.clone(), + workspace_scope: registration + .workspace_scope + .clone() + .unwrap_or_default(), + generation_key: generation_key.clone(), + revision: revision.clone(), + hook_name: hook_name.clone(), + input: payload.to_json(), + output: Value::Object(Default::default()), + }), + ) .await; - if finalized { - break 'rules; + if let Err(error) = result.unwrap_or_else(|_| Err("timed out".to_string())) { + outcome.warnings.push(format!( + "Plugin hook '{}' for {event} failed: {error}", + registration.plan.id() + )); + } + false } + }; + if finalized { + break; } } outcome } + /// Execute one provider hook snapshot and carry input/output mutations + /// forward in registry order. + pub async fn dispatch_plugin_hook( + &self, + workspace_scope: Option<&str>, + hook_name: &str, + input: Value, + output: Value, + ) -> PluginHookDispatchResult { + self.dispatch_plugin_hook_for_generation(workspace_scope, None, hook_name, input, output) + .await + } + + pub async fn dispatch_plugin_hook_for_generation( + &self, + workspace_scope: Option<&str>, + generation: Option<&super::handler::PluginHookGenerationIdentity>, + hook_name: &str, + mut input: Value, + mut output: Value, + ) -> PluginHookDispatchResult { + let kind = RuntimeHookKind::PluginHook(hook_name.to_string()); + let registrations = match (workspace_scope, generation) { + (Some(workspace_scope), Some(generation)) => self + .registry + .registrations_for_plugin_generation(kind.clone(), workspace_scope, generation), + _ => self + .registry + .registrations_for_workspace(kind.clone(), workspace_scope), + }; + let mut result = PluginHookDispatchResult::default(); + for registration in registrations.iter() { + let HookHandler::Plugin { + executor, + instance_id, + hook_name, + generation_key, + revision, + .. + } = ®istration.handler + else { + continue; + }; + result.executed_handlers += 1; + let invocation = executor.execute(PluginHookCall { + instance_id: instance_id.clone(), + workspace_scope: registration.workspace_scope.clone().unwrap_or_default(), + generation_key: generation_key.clone(), + revision: revision.clone(), + hook_name: hook_name.clone(), + input: input.clone(), + output: output.clone(), + }); + match tokio::time::timeout( + Duration::from_millis(registration.plan.timeout_millis()), + invocation, + ) + .await + { + Ok(Ok(transformed)) => { + if transformed.instance_id != *instance_id + || transformed.generation_key != *generation_key + || transformed.revision != *revision + || transformed.hook_name != *hook_name + { + let failure = format!( + "Plugin hook '{}' returned a mismatched generation lease", + registration.plan.id() + ); + if plugin_error_stops_dispatch(registration.plan.error_policy()) { + result.failure = Some(failure); + break; + } + result.warnings.push(failure); + continue; + } + let generation_still_dispatchable = match (workspace_scope, generation) { + (Some(scope), Some(requested)) => self + .registry + .registrations_for_plugin_generation(kind.clone(), scope, requested) + .iter() + .any(|current| current.plan.id() == registration.plan.id()), + (Some(scope), None) => self + .registry + .registrations_for_workspace(kind.clone(), Some(scope)) + .iter() + .any(|current| current.plan.id() == registration.plan.id()), + _ => true, + }; + if !generation_still_dispatchable { + let failure = format!( + "Plugin hook '{}' generation is no longer active", + registration.plan.id() + ); + if plugin_error_stops_dispatch(registration.plan.error_policy()) { + result.failure = Some(failure); + break; + } + result.warnings.push(failure); + continue; + } + input = transformed.input; + output = transformed.output; + } + Ok(Err(error)) => { + let failure = + format!("Plugin hook '{}' failed: {error}", registration.plan.id()); + if plugin_error_stops_dispatch(registration.plan.error_policy()) { + result.failure = Some(failure); + break; + } + result.warnings.push(failure); + } + Err(_) => { + let failure = format!( + "Plugin hook '{}' timed out after {}ms", + registration.plan.id(), + registration.plan.timeout_millis() + ); + if plugin_error_stops_dispatch(registration.plan.error_policy()) { + result.failure = Some(failure); + break; + } + result.warnings.push(failure); + } + } + } + result.input = input; + result.output = output; + result + } + + pub async fn dispatch_call( + &self, + workspace_scope: Option<&str>, + call: &HookCall, + ) -> HookHandlerResult { + let registrations = self + .registry + .registrations_for_workspace(call.kind.clone(), workspace_scope); + let mut result = HookHandlerResult::default(); + for registration in registrations.iter() { + if let HookHandler::Builtin { executor } = ®istration.handler { + merge_handler_result(executor.execute(call).await, &mut result); + } + } + result + } + /// Run one handler and fold its result into `outcome`. Returns `true` /// when the dispatch is finalized (blocked or denied) and remaining /// handlers must not run. @@ -153,6 +388,68 @@ impl AgentHookEngine { } } +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookDispatchResult { + pub input: Value, + pub output: Value, + pub warnings: Vec, + pub failure: Option, + pub executed_handlers: usize, +} + +impl Default for PluginHookDispatchResult { + fn default() -> Self { + Self { + input: Value::Null, + output: Value::Null, + warnings: Vec::new(), + failure: None, + executed_handlers: 0, + } + } +} + +fn plugin_error_stops_dispatch(policy: RuntimeHookErrorPolicy) -> bool { + matches!( + policy, + RuntimeHookErrorPolicy::FailTurn | RuntimeHookErrorPolicy::DenyTool + ) +} + +fn lifecycle_call(payload: &AgentHookPayload, cwd: &Path) -> HookCall { + HookCall { + kind: RuntimeHookKind::Lifecycle(payload.event()), + cwd: cwd.to_path_buf(), + session_id: Some(payload.common.session_id.clone()), + turn_id: payload.common.turn_id.clone(), + workspace_root: Some(cwd.to_path_buf()), + is_remote: false, + model: Some(payload.common.model.clone()), + bypass_permissions: matches!( + payload.common.permission_mode, + super::payload::AgentHookPermissionMode::BypassPermissions + ), + payload: HookCallPayload::Lifecycle(payload.event.clone()), + } +} + +fn apply_handler_result(result: HookHandlerResult, outcome: &mut AgentHookOutcome) -> bool { + outcome.warnings.extend(result.warnings); + outcome.additional_context.extend(result.additional_context); + if outcome.block_reason.is_none() { + outcome.block_reason = result.block_reason; + } + outcome.is_blocked() +} + +fn merge_handler_result(result: HookHandlerResult, outcome: &mut HookHandlerResult) { + outcome.warnings.extend(result.warnings); + outcome.additional_context.extend(result.additional_context); + if outcome.block_reason.is_none() { + outcome.block_reason = result.block_reason; + } +} + enum HookCommandRun { Completed { exit_code: Option, @@ -262,3 +559,167 @@ pub(crate) fn truncate_model_output(text: &str) -> String { } format!("{}\n[hook output truncated]", &text[..end]) } + +#[cfg(test)] +mod plugin_dispatch_tests { + use super::AgentHookEngine; + use crate::native_hooks::{ + AgentHookMatcher, PluginHookCall, PluginHookExecutor, PluginHookResult, + RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistration, + RuntimeHookRegistry, RuntimeHookSource, + }; + use async_trait::async_trait; + use serde_json::json; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + struct FailingExecutor; + + #[async_trait] + impl PluginHookExecutor for FailingExecutor { + async fn execute(&self, _call: PluginHookCall) -> Result { + Err("before hook failed".to_string()) + } + } + + struct CountingExecutor(Arc); + + #[async_trait] + impl PluginHookExecutor for CountingExecutor { + async fn execute(&self, call: PluginHookCall) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(PluginHookResult { + instance_id: call.instance_id, + generation_key: call.generation_key, + revision: call.revision, + hook_name: call.hook_name, + input: call.input, + output: call.output, + }) + } + } + + struct BlockingExecutor { + started: Arc, + release: Arc, + } + + #[async_trait] + impl PluginHookExecutor for BlockingExecutor { + async fn execute(&self, call: PluginHookCall) -> Result { + self.started.store(true, Ordering::SeqCst); + while !self.release.load(Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(1)).await; + } + Ok(PluginHookResult { + instance_id: call.instance_id, + generation_key: call.generation_key, + revision: call.revision, + hook_name: call.hook_name, + input: call.input, + output: json!({"args": {"stale": true}}), + }) + } + } + + #[tokio::test] + async fn deny_tool_plugin_hook_failure_stops_the_ordered_chain() { + let registry = RuntimeHookRegistry::default(); + let calls = Arc::new(AtomicUsize::new(0)); + let registration = |id: &str, order, executor: Arc| { + RuntimeHookRegistration::plugin( + RuntimeHookPlan::new( + id, + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + RuntimeHookSource::Plugin, + ) + .with_order(order) + .with_error_policy(RuntimeHookErrorPolicy::DenyTool), + "tool.execute.before", + "instance-a", + "generation-a", + "revision-a", + executor, + AgentHookMatcher::Any, + ) + .with_workspace_scope("workspace-a") + }; + let token = registry + .register_plugin_batch(vec![ + registration("first", 1, Arc::new(FailingExecutor)), + registration("second", 2, Arc::new(CountingExecutor(calls.clone()))), + ]) + .expect("plugin hooks register"); + registry.activate_plugin_batch("workspace-a", Some(&token)); + + let result = AgentHookEngine::with_registry(registry) + .dispatch_plugin_hook( + Some("workspace-a"), + "tool.execute.before", + json!({}), + json!({"args": {}}), + ) + .await; + + assert_eq!( + result.failure.as_deref(), + Some("Plugin hook 'first' failed: before hook failed") + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(result.executed_handlers, 1); + } + + #[tokio::test] + async fn retired_plugin_generation_cannot_publish_a_late_hook_result() { + let registry = RuntimeHookRegistry::default(); + let started = Arc::new(AtomicBool::new(false)); + let release = Arc::new(AtomicBool::new(false)); + let registration = RuntimeHookRegistration::plugin( + RuntimeHookPlan::new( + "stale", + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + RuntimeHookSource::Plugin, + ) + .with_timeout_millis(5_000) + .with_error_policy(RuntimeHookErrorPolicy::DenyTool), + "tool.execute.before", + "instance-a", + "generation-a", + "revision-a", + Arc::new(BlockingExecutor { + started: started.clone(), + release: release.clone(), + }), + AgentHookMatcher::Any, + ) + .with_workspace_scope("workspace-a"); + let token = registry + .register_plugin_batch(vec![registration]) + .expect("plugin hook registers"); + registry.activate_plugin_batch("workspace-a", Some(&token)); + let engine = AgentHookEngine::with_registry(registry.clone()); + let dispatch = tokio::spawn(async move { + engine + .dispatch_plugin_hook( + Some("workspace-a"), + "tool.execute.before", + json!({}), + json!({"args": {}}), + ) + .await + }); + while !started.load(Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(1)).await; + } + registry.withdraw_plugin_workspace("workspace-a"); + release.store(true, Ordering::SeqCst); + + let result = dispatch.await.expect("dispatch joins"); + assert_eq!( + result.failure.as_deref(), + Some("Plugin hook 'stale' generation is no longer active") + ); + assert_eq!(result.output, json!({"args": {}})); + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/handler.rs b/src/crates/execution/agent-runtime/src/native_hooks/handler.rs new file mode 100644 index 0000000000..5253c57079 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/handler.rs @@ -0,0 +1,151 @@ +//! Executable hook handler variants. + +use super::call::HookCall; +use super::kind::{RuntimeHookKind, RuntimeHookSource}; +use super::registry::RuntimeHookPlan; +use super::settings::{AgentHookHandler, AgentHookMatcher}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +#[derive(Clone)] +pub enum HookHandler { + Command(AgentHookHandler), + Plugin { + executor: Arc, + hook_name: String, + instance_id: String, + generation_key: String, + revision: String, + }, + Builtin { + executor: Arc, + }, +} + +impl std::fmt::Debug for HookHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Command(handler) => f.debug_tuple("Command").field(handler).finish(), + Self::Plugin { + hook_name, + instance_id, + generation_key, + revision, + .. + } => f + .debug_struct("Plugin") + .field("hook_name", hook_name) + .field("instance_id", instance_id) + .field("generation_key", generation_key) + .field("revision", revision) + .finish_non_exhaustive(), + Self::Builtin { .. } => f.debug_struct("Builtin").finish_non_exhaustive(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct HookHandlerResult { + pub warnings: Vec, + pub block_reason: Option, + pub additional_context: Vec, +} + +#[async_trait] +pub trait BuiltinHookExecutor: Send + Sync { + async fn execute(&self, call: &HookCall) -> HookHandlerResult; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookCall { + pub instance_id: String, + pub workspace_scope: String, + pub generation_key: String, + pub revision: String, + pub hook_name: String, + pub input: Value, + pub output: Value, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookResult { + pub instance_id: String, + pub generation_key: String, + pub revision: String, + pub hook_name: String, + pub input: Value, + pub output: Value, +} + +#[async_trait] +pub trait PluginHookExecutor: Send + Sync { + async fn execute(&self, call: PluginHookCall) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginHookGenerationIdentity { + pub instance_id: String, + pub generation_key: String, + pub revision: String, +} + +#[derive(Clone, Debug)] +pub struct RuntimeHookRegistration { + pub plan: RuntimeHookPlan, + pub handler: HookHandler, + pub matcher: AgentHookMatcher, + pub workspace_scope: Option, +} + +impl RuntimeHookRegistration { + pub fn new(plan: RuntimeHookPlan, handler: HookHandler, matcher: AgentHookMatcher) -> Self { + Self { + plan, + handler, + matcher, + workspace_scope: None, + } + } + + pub fn with_workspace_scope(mut self, workspace_scope: impl Into) -> Self { + self.workspace_scope = Some(workspace_scope.into()); + self + } + + pub fn command( + id: impl Into, + kind: RuntimeHookKind, + source: RuntimeHookSource, + handler: AgentHookHandler, + matcher: AgentHookMatcher, + ) -> Self { + Self::new( + RuntimeHookPlan::new(id, kind, source), + HookHandler::Command(handler), + matcher, + ) + } + + pub fn plugin( + plan: RuntimeHookPlan, + hook_name: impl Into, + instance_id: impl Into, + generation_key: impl Into, + revision: impl Into, + executor: Arc, + matcher: AgentHookMatcher, + ) -> Self { + Self::new( + plan, + HookHandler::Plugin { + executor, + hook_name: hook_name.into(), + instance_id: instance_id.into(), + generation_key: generation_key.into(), + revision: revision.into(), + }, + matcher, + ) + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/kind.rs b/src/crates/execution/agent-runtime/src/native_hooks/kind.rs new file mode 100644 index 0000000000..331beac25d --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/kind.rs @@ -0,0 +1,47 @@ +//! Stable hook categories and sources used by the portable hook registry. + +use super::settings::AgentHookEvent; +use std::fmt; + +/// The Codex lifecycle events plus BitFun/OpenCode execution categories. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum RuntimeHookKind { + Lifecycle(AgentHookEvent), + SuccessfulToolPostCall, + PluginHook(String), +} + +/// Origin of a registered hook. The declaration order is the stable source +/// precedence used when snapshots are sorted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum RuntimeHookSource { + Builtin { + priority: u16, + }, + UserCommand, + ProjectCommand, + ImportedCommand, + /// Executable plugin contribution. The concrete ecosystem is owned by an + /// adapter; the portable runtime only models trust/source precedence. + Plugin, +} + +impl RuntimeHookSource { + pub const fn is_plugin(self) -> bool { + matches!(self, Self::Plugin) + } +} + +impl fmt::Display for RuntimeHookSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Builtin { .. } => f.write_str("builtin"), + Self::UserCommand => f.write_str("user-command"), + Self::ProjectCommand => f.write_str("project-command"), + Self::ImportedCommand => f.write_str("imported-command"), + Self::Plugin => f.write_str("plugin"), + } + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/mod.rs b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs index c3f0a2cc70..321c2e513b 100644 --- a/src/crates/execution/agent-runtime/src/native_hooks/mod.rs +++ b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs @@ -16,22 +16,45 @@ //! - the external hook catalog (`bitfun-product-domains`): read-only //! inspection of other AI applications' hook configuration. +#[cfg(feature = "native-hook-runtime")] +mod call; #[cfg(feature = "native-hook-runtime")] mod engine; #[cfg(feature = "native-hook-runtime")] +mod handler; +#[cfg(feature = "native-hook-runtime")] +mod kind; +#[cfg(feature = "native-hook-runtime")] mod output; #[cfg(feature = "native-hook-runtime")] mod payload; +#[cfg(feature = "native-hook-runtime")] +mod registry; mod settings; #[cfg(feature = "native-hook-runtime")] -pub use engine::{AgentHookEngine, MAX_HOOK_MODEL_OUTPUT_BYTES}; +pub use call::{HookCall, HookCallPayload}; +#[cfg(feature = "native-hook-runtime")] +pub use engine::{AgentHookEngine, PluginHookDispatchResult, MAX_HOOK_MODEL_OUTPUT_BYTES}; +#[cfg(feature = "native-hook-runtime")] +pub use handler::{ + BuiltinHookExecutor, HookHandler, HookHandlerResult, PluginHookCall, PluginHookExecutor, + PluginHookGenerationIdentity, PluginHookResult, RuntimeHookRegistration, +}; +#[cfg(feature = "native-hook-runtime")] +pub use kind::{RuntimeHookKind, RuntimeHookSource}; #[cfg(feature = "native-hook-runtime")] pub use output::{AgentHookOutcome, AgentHookPermissionOutcome}; #[cfg(feature = "native-hook-runtime")] pub use payload::{ AgentHookEventPayload, AgentHookPayload, AgentHookPayloadCommon, AgentHookPermissionMode, }; +#[cfg(feature = "native-hook-runtime")] +pub use registry::{ + RuntimeHookActivation, RuntimeHookCommitToken, RuntimeHookErrorPolicy, RuntimeHookPlan, + RuntimeHookRegistry, RuntimeHookRegistryBuildError, RuntimeHookRegistryBuilder, + RuntimeHookRegistryError, +}; pub use settings::{ AgentHookEvent, AgentHookHandler, AgentHookMatcher, AgentHookRule, AgentHookScope, AgentHookSettings, AgentHookSettingsIssue, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, diff --git a/src/crates/execution/agent-runtime/src/native_hooks/registry.rs b/src/crates/execution/agent-runtime/src/native_hooks/registry.rs new file mode 100644 index 0000000000..0fcdb26d3d --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/registry.rs @@ -0,0 +1,621 @@ +//! Shared runtime hook registry. + +use super::handler::{HookHandler, RuntimeHookRegistration}; +use super::kind::{RuntimeHookKind, RuntimeHookSource}; +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RuntimeHookActivation { + Preparing, + Ready, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RuntimeHookErrorPolicy { + FailTurn, + SkipHook, + DenyTool, + RecordWarning, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeHookPlan { + id: String, + kind: RuntimeHookKind, + source: RuntimeHookSource, + order: u16, + timeout_millis: u64, + error_policy: RuntimeHookErrorPolicy, +} + +impl RuntimeHookPlan { + pub fn new(id: impl Into, kind: RuntimeHookKind, source: RuntimeHookSource) -> Self { + Self { + id: id.into(), + kind, + source, + order: 100, + timeout_millis: 1_000, + error_policy: RuntimeHookErrorPolicy::RecordWarning, + } + } + + pub fn with_order(mut self, order: u16) -> Self { + self.order = order; + self + } + + pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self { + self.timeout_millis = timeout_millis; + self + } + + pub fn with_error_policy(mut self, error_policy: RuntimeHookErrorPolicy) -> Self { + self.error_policy = error_policy; + self + } + + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + pub fn with_source(mut self, source: RuntimeHookSource) -> Self { + self.source = source; + self + } + + pub fn id(&self) -> &str { + &self.id + } + pub const fn kind(&self) -> &RuntimeHookKind { + &self.kind + } + pub const fn source(&self) -> RuntimeHookSource { + self.source + } + pub const fn order(&self) -> u16 { + self.order + } + pub const fn timeout_millis(&self) -> u64 { + self.timeout_millis + } + pub const fn error_policy(&self) -> RuntimeHookErrorPolicy { + self.error_policy + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RuntimeHookRegistryBuildError { + #[error("runtime hook id must not be empty")] + EmptyHookId, + #[error("runtime hook {hook_id} must declare a non-zero timeout")] + InvalidTimeoutMillis { hook_id: String }, + #[error("duplicate runtime hook id {hook_id}")] + DuplicateHookId { hook_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RuntimeHookRegistryError { + #[error(transparent)] + Validation(#[from] RuntimeHookRegistryBuildError), + #[error("runtime hook source {hook_source} cannot be replaced")] + InvalidReplacementSource { hook_source: RuntimeHookSource }, + #[error("OpenCode plugin hook batch must contain one target and revision")] + InvalidPluginBatch, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeHookCommitToken { + workspace_scope: String, + target_id: String, + generation_key: String, + revision: String, + hook_ids: Arc<[String]>, +} + +impl RuntimeHookCommitToken { + pub fn workspace_scope(&self) -> &str { + &self.workspace_scope + } + + pub fn target_id(&self) -> &str { + &self.target_id + } + pub fn revision(&self) -> &str { + &self.revision + } + + pub fn generation_key(&self) -> &str { + &self.generation_key + } +} + +#[derive(Debug, Clone, Default)] +pub struct RuntimeHookRegistryBuilder { + hooks: Vec, +} + +impl RuntimeHookRegistryBuilder { + pub fn register(mut self, hook: RuntimeHookRegistration) -> Self { + self.hooks.push(hook); + self + } + + pub fn build(self) -> Result { + validate_entries(&self.hooks).map_err(|error| match error { + RuntimeHookRegistryError::Validation(error) => error, + RuntimeHookRegistryError::InvalidReplacementSource { .. } => { + RuntimeHookRegistryBuildError::EmptyHookId + } + RuntimeHookRegistryError::InvalidPluginBatch => { + RuntimeHookRegistryBuildError::EmptyHookId + } + })?; + let registry = RuntimeHookRegistry::default(); + registry + .replace_state(self.hooks) + .map_err(|error| match error { + RuntimeHookRegistryError::Validation(error) => error, + RuntimeHookRegistryError::InvalidReplacementSource { .. } => { + RuntimeHookRegistryBuildError::EmptyHookId + } + RuntimeHookRegistryError::InvalidPluginBatch => { + RuntimeHookRegistryBuildError::EmptyHookId + } + })?; + Ok(registry) + } +} + +struct RuntimeHookRegistryState { + entries: BTreeMap>, + source_activation: BTreeMap<(RuntimeHookSource, Option), RuntimeHookActivation>, + active_plugin_generations: BTreeMap, +} + +impl Default for RuntimeHookRegistryState { + fn default() -> Self { + Self { + entries: BTreeMap::new(), + source_activation: BTreeMap::new(), + active_plugin_generations: BTreeMap::new(), + } + } +} + +#[derive(Clone, Default)] +pub struct RuntimeHookRegistry { + inner: Arc>, +} + +impl fmt::Debug for RuntimeHookRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let plans = self.plans(); + f.debug_struct("RuntimeHookRegistry") + .field("plans", &plans) + .field("count", &plans.len()) + .finish() + } +} + +impl RuntimeHookRegistry { + pub fn builder() -> RuntimeHookRegistryBuilder { + RuntimeHookRegistryBuilder::default() + } + + pub fn register_batch( + &self, + entries: Vec, + ) -> Result<(), RuntimeHookRegistryError> { + validate_entries(&entries)?; + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let mut merged = state + .entries + .values() + .flat_map(|items| items.iter().cloned()) + .collect::>(); + merged.extend(entries); + validate_entries(&merged)?; + rebuild_entries(&mut state.entries, merged); + Ok(()) + } + + pub fn register_plugin_batch( + &self, + entries: Vec, + ) -> Result { + let (workspace_scope, target_id, generation_key, revision) = + plugin_batch_identity(&entries)?; + let hook_ids = entries + .iter() + .map(|entry| entry.plan.id().to_string()) + .collect::>(); + self.register_batch(entries)?; + Ok(RuntimeHookCommitToken { + workspace_scope, + target_id, + generation_key, + revision, + hook_ids: Arc::from(hook_ids), + }) + } + + pub fn rollback_plugin_batch(&self, token: &RuntimeHookCommitToken) { + let hook_ids = token + .hook_ids + .iter() + .map(String::as_str) + .collect::>(); + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let retained = state.entries.values().flat_map(|items| items.iter().cloned()).filter(|entry| { + if !hook_ids.contains(entry.plan.id()) { return true; } + !matches!(&entry.handler, HookHandler::Plugin { instance_id, generation_key, revision, .. } + if entry.workspace_scope.as_deref() == Some(token.workspace_scope.as_str()) + && instance_id == &token.target_id + && generation_key == &token.generation_key + && revision == &token.revision) + }).collect::>(); + rebuild_entries(&mut state.entries, retained); + } + + pub fn replace_command_source( + &self, + source: RuntimeHookSource, + workspace_scope: Option<&str>, + entries: Vec, + ) -> Result<(), RuntimeHookRegistryError> { + if !matches!( + source, + RuntimeHookSource::UserCommand + | RuntimeHookSource::ProjectCommand + | RuntimeHookSource::ImportedCommand + ) { + return Err(RuntimeHookRegistryError::InvalidReplacementSource { + hook_source: source, + }); + } + validate_entries(&entries)?; + if entries.iter().any(|entry| entry.plan.source() != source) { + return Err(RuntimeHookRegistryError::InvalidReplacementSource { + hook_source: source, + }); + } + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let mut merged = state + .entries + .values() + .flat_map(|items| items.iter().cloned()) + .filter(|entry| { + entry.plan.source() != source || entry.workspace_scope.as_deref() != workspace_scope + }) + .collect::>(); + merged.extend(entries); + validate_entries(&merged)?; + rebuild_entries(&mut state.entries, merged); + Ok(()) + } + + pub fn plans(&self) -> Vec { + let state = self.inner.read().expect("hook registry lock poisoned"); + state + .entries + .values() + .flat_map(|items| items.iter().map(|entry| entry.plan.clone())) + .collect() + } + + /// Compatibility spelling for internal callers while migration completes. + pub fn hooks(&self) -> Vec { + self.plans() + } + + pub fn registrations_for(&self, kind: RuntimeHookKind) -> Arc<[RuntimeHookRegistration]> { + self.registrations_for_workspace(kind, None) + } + + pub fn registrations_for_workspace( + &self, + kind: RuntimeHookKind, + workspace_scope: Option<&str>, + ) -> Arc<[RuntimeHookRegistration]> { + self.registrations_for_workspace_generation(kind, workspace_scope, None) + } + + pub fn registrations_for_plugin_generation( + &self, + kind: RuntimeHookKind, + workspace_scope: &str, + generation: &super::handler::PluginHookGenerationIdentity, + ) -> Arc<[RuntimeHookRegistration]> { + self.registrations_for_workspace_generation(kind, Some(workspace_scope), Some(generation)) + } + + fn registrations_for_workspace_generation( + &self, + kind: RuntimeHookKind, + workspace_scope: Option<&str>, + requested_plugin_generation: Option<&super::handler::PluginHookGenerationIdentity>, + ) -> Arc<[RuntimeHookRegistration]> { + let state = self.inner.read().expect("hook registry lock poisoned"); + let entries = state + .entries + .get(&kind) + .cloned() + .unwrap_or_else(|| Arc::from([])); + let plugin_activation = state + .source_activation + .get(&( + RuntimeHookSource::Plugin, + workspace_scope.map(str::to_string), + )) + .or_else(|| { + state + .source_activation + .get(&(RuntimeHookSource::Plugin, None)) + }) + .copied() + .unwrap_or(RuntimeHookActivation::Unavailable); + let active_plugin_generation = + workspace_scope.and_then(|workspace| state.active_plugin_generations.get(workspace)); + Arc::from( + entries + .iter() + .filter(|entry| { + let workspace_matches = match (&entry.workspace_scope, workspace_scope) { + (Some(expected), Some(actual)) => expected == actual, + (None, _) => true, + (Some(_), None) => false, + }; + let plugin_generation_matches = if !entry.plan.source().is_plugin() { + true + } else if plugin_activation != RuntimeHookActivation::Ready { + false + } else { + match (&entry.handler, requested_plugin_generation) { + ( + HookHandler::Plugin { + instance_id, + generation_key, + revision, + .. + }, + Some(requested), + ) => { + instance_id == &requested.instance_id + && generation_key == &requested.generation_key + && revision == &requested.revision + } + ( + HookHandler::Plugin { + instance_id, + generation_key, + revision, + .. + }, + None, + ) => { + matches!(active_plugin_generation, + Some((active_instance, active_generation, active_revision)) + if instance_id == active_instance + && generation_key == active_generation + && revision == active_revision) + } + _ => false, + } + }; + workspace_matches && plugin_generation_matches + }) + .cloned() + .collect::>(), + ) + } + + pub fn source_activation(&self, source: RuntimeHookSource) -> RuntimeHookActivation { + self.source_activation_for_workspace(source, None) + } + + pub fn source_activation_for_workspace( + &self, + source: RuntimeHookSource, + workspace_scope: Option<&str>, + ) -> RuntimeHookActivation { + let state = self.inner.read().expect("hook registry lock poisoned"); + state + .source_activation + .get(&(source, workspace_scope.map(str::to_string))) + .or_else(|| state.source_activation.get(&(source, None))) + .copied() + .unwrap_or_else(|| { + if source.is_plugin() { + RuntimeHookActivation::Unavailable + } else { + RuntimeHookActivation::Ready + } + }) + } + + pub fn set_source_activation( + &self, + source: RuntimeHookSource, + activation: RuntimeHookActivation, + ) { + self.set_source_activation_for_workspace(source, None, activation); + } + + pub fn set_source_activation_for_workspace( + &self, + source: RuntimeHookSource, + workspace_scope: Option<&str>, + activation: RuntimeHookActivation, + ) { + self.inner + .write() + .expect("hook registry lock poisoned") + .source_activation + .insert((source, workspace_scope.map(str::to_string)), activation); + } + + pub fn activate_plugin_batch( + &self, + workspace_scope: &str, + token: Option<&RuntimeHookCommitToken>, + ) { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + if let Some(token) = token { + debug_assert_eq!(token.workspace_scope(), workspace_scope); + state.active_plugin_generations.insert( + workspace_scope.to_string(), + ( + token.target_id().to_string(), + token.generation_key().to_string(), + token.revision().to_string(), + ), + ); + } else { + state.active_plugin_generations.remove(workspace_scope); + } + state.source_activation.insert( + (RuntimeHookSource::Plugin, Some(workspace_scope.to_string())), + RuntimeHookActivation::Ready, + ); + } + + pub fn withdraw_plugin_workspace(&self, workspace_scope: &str) { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + state.active_plugin_generations.remove(workspace_scope); + state.source_activation.insert( + (RuntimeHookSource::Plugin, Some(workspace_scope.to_string())), + RuntimeHookActivation::Unavailable, + ); + } + + pub fn clear_source_workspace(&self, source: RuntimeHookSource, workspace_scope: &str) { + self.clear_source_partition(source, Some(workspace_scope)); + } + + pub fn clear_source_partition(&self, source: RuntimeHookSource, workspace_scope: Option<&str>) { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let retained = state + .entries + .values() + .flat_map(|items| items.iter().cloned()) + .filter(|entry| { + entry.plan.source() != source || entry.workspace_scope.as_deref() != workspace_scope + }) + .collect::>(); + rebuild_entries(&mut state.entries, retained); + state + .source_activation + .remove(&(source, workspace_scope.map(str::to_string))); + if source == RuntimeHookSource::Plugin { + if let Some(workspace_scope) = workspace_scope { + state.active_plugin_generations.remove(workspace_scope); + } + } + } + + fn replace_state( + &self, + entries: Vec, + ) -> Result<(), RuntimeHookRegistryError> { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + rebuild_entries(&mut state.entries, entries); + Ok(()) + } +} + +fn validate_entries(entries: &[RuntimeHookRegistration]) -> Result<(), RuntimeHookRegistryError> { + let mut ids = HashSet::with_capacity(entries.len()); + for entry in entries { + let plan = &entry.plan; + if plan.id().trim().is_empty() { + return Err(RuntimeHookRegistryBuildError::EmptyHookId.into()); + } + if plan.timeout_millis() == 0 { + return Err(RuntimeHookRegistryBuildError::InvalidTimeoutMillis { + hook_id: plan.id().to_string(), + } + .into()); + } + if !ids.insert(plan.id().to_string()) { + return Err(RuntimeHookRegistryBuildError::DuplicateHookId { + hook_id: plan.id().to_string(), + } + .into()); + } + } + Ok(()) +} + +fn plugin_batch_identity( + entries: &[RuntimeHookRegistration], +) -> Result<(String, String, String, String), RuntimeHookRegistryError> { + let mut identity = None::<(String, String, String, String)>; + for entry in entries { + if entry.plan.source() != RuntimeHookSource::Plugin { + return Err(RuntimeHookRegistryError::InvalidPluginBatch); + } + let HookHandler::Plugin { + instance_id, + generation_key, + revision, + .. + } = &entry.handler + else { + return Err(RuntimeHookRegistryError::InvalidPluginBatch); + }; + let Some(workspace_scope) = entry.workspace_scope.as_ref() else { + return Err(RuntimeHookRegistryError::InvalidPluginBatch); + }; + match &identity { + Some((expected_workspace, expected_target, expected_generation, expected_revision)) + if expected_workspace != workspace_scope + || expected_target != instance_id + || expected_generation != generation_key + || expected_revision != revision => + { + return Err(RuntimeHookRegistryError::InvalidPluginBatch) + } + None => { + identity = Some(( + workspace_scope.clone(), + instance_id.clone(), + generation_key.clone(), + revision.clone(), + )) + } + Some(_) => {} + } + } + identity.ok_or(RuntimeHookRegistryError::InvalidPluginBatch) +} + +fn rebuild_entries( + map: &mut BTreeMap>, + entries: Vec, +) { + map.clear(); + let mut by_kind = BTreeMap::>::new(); + for entry in entries { + by_kind + .entry(entry.plan.kind().clone()) + .or_default() + .push(entry); + } + for (kind, mut entries) in by_kind { + entries.sort_by(|left, right| { + left.plan + .source() + .cmp(&right.plan.source()) + .then_with(|| left.plan.order().cmp(&right.plan.order())) + .then_with(|| left.plan.id().cmp(right.plan.id())) + }); + map.insert(kind, Arc::from(entries)); + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/settings.rs b/src/crates/execution/agent-runtime/src/native_hooks/settings.rs index 5f5d185c9e..7c39f4a541 100644 --- a/src/crates/execution/agent-runtime/src/native_hooks/settings.rs +++ b/src/crates/execution/agent-runtime/src/native_hooks/settings.rs @@ -412,6 +412,40 @@ impl AgentHookSettings { .map(|rule| rule.handlers.len()) .sum() } + + /// Converts parsed command handlers into the executable registrations used + /// by the shared runtime registry. Parsing remains independent from + /// registry publication so callers can inspect issues before publishing. + #[cfg(feature = "native-hook-runtime")] + pub fn registrations(&self) -> Vec { + let mut registrations = Vec::new(); + for (event, rules) in &self.rules { + for (rule_index, rule) in rules.iter().enumerate() { + let source = match rule.scope { + AgentHookScope::User => crate::native_hooks::RuntimeHookSource::UserCommand, + AgentHookScope::Project => { + crate::native_hooks::RuntimeHookSource::ProjectCommand + } + }; + for (handler_index, handler) in rule.handlers.iter().enumerate() { + let id = format!( + "command.{}.{}.{}", + event.as_str(), + rule_index, + handler_index + ); + registrations.push(crate::native_hooks::RuntimeHookRegistration::command( + id, + crate::native_hooks::RuntimeHookKind::Lifecycle(*event), + source, + handler.clone(), + rule.matcher.clone(), + )); + } + } + } + registrations + } } fn parse_layer( diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index a8ae2ac199..e057b743ed 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -1,149 +1,13 @@ //! Portable post-call hook routing decisions. +pub use crate::native_hooks::{ + RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistry, + RuntimeHookRegistryBuildError, RuntimeHookRegistryBuilder, +}; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; -/// Hook categories that concrete runtime integrations may execute after a -/// successful tool call. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum RuntimeHookKind { - SuccessfulToolPostCall, - DeepReviewSharedContextToolUse, -} - -pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 1] { - [RuntimeHookKind::DeepReviewSharedContextToolUse] -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum RuntimeHookErrorPolicy { - FailTurn, - SkipHook, - DenyTool, - RecordWarning, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeHookPlan { - id: String, - kind: RuntimeHookKind, - order: u16, - timeout_millis: u64, - error_policy: RuntimeHookErrorPolicy, -} - -impl RuntimeHookPlan { - pub fn new(id: impl Into, kind: RuntimeHookKind) -> Self { - Self { - id: id.into(), - kind, - order: 100, - timeout_millis: 1_000, - error_policy: RuntimeHookErrorPolicy::RecordWarning, - } - } - - pub fn with_order(mut self, order: u16) -> Self { - self.order = order; - self - } - - pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self { - self.timeout_millis = timeout_millis; - self - } - - pub fn with_error_policy(mut self, error_policy: RuntimeHookErrorPolicy) -> Self { - self.error_policy = error_policy; - self - } - - pub fn id(&self) -> &str { - &self.id - } - - pub const fn kind(&self) -> RuntimeHookKind { - self.kind - } - - pub const fn order(&self) -> u16 { - self.order - } - - pub const fn timeout_millis(&self) -> u64 { - self.timeout_millis - } - - pub const fn error_policy(&self) -> RuntimeHookErrorPolicy { - self.error_policy - } -} - -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum RuntimeHookRegistryBuildError { - #[error("runtime hook id must not be empty")] - EmptyHookId, - #[error("runtime hook {hook_id} must declare a non-zero timeout")] - InvalidTimeoutMillis { hook_id: String }, - #[error("duplicate runtime hook id {hook_id}")] - DuplicateHookId { hook_id: String }, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RuntimeHookRegistryBuilder { - hooks: Vec, -} - -impl RuntimeHookRegistryBuilder { - pub fn register(mut self, hook: RuntimeHookPlan) -> Self { - self.hooks.push(hook); - self - } - - pub fn build(mut self) -> Result { - let mut hook_ids = HashSet::new(); - for hook in &self.hooks { - if hook.id.trim().is_empty() { - return Err(RuntimeHookRegistryBuildError::EmptyHookId); - } - if hook.timeout_millis == 0 { - return Err(RuntimeHookRegistryBuildError::InvalidTimeoutMillis { - hook_id: hook.id.clone(), - }); - } - if !hook_ids.insert(hook.id.clone()) { - return Err(RuntimeHookRegistryBuildError::DuplicateHookId { - hook_id: hook.id.clone(), - }); - } - } - self.hooks.sort_by(|left, right| { - left.order - .cmp(&right.order) - .then_with(|| left.id.cmp(&right.id)) - }); - Ok(RuntimeHookRegistry { hooks: self.hooks }) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RuntimeHookRegistry { - hooks: Vec, -} - -impl RuntimeHookRegistry { - pub fn builder() -> RuntimeHookRegistryBuilder { - RuntimeHookRegistryBuilder::default() - } - - pub fn hooks(&self) -> &[RuntimeHookPlan] { - &self.hooks - } -} - pub trait SuccessfulToolPostCallHookExecutor { fn record_deep_review_shared_context_tool_use( &mut self, @@ -161,14 +25,7 @@ pub fn run_successful_tool_post_call_hooks( ) where E: SuccessfulToolPostCallHookExecutor, { - for hook in successful_tool_post_call_hooks() { - match hook { - RuntimeHookKind::DeepReviewSharedContextToolUse => { - executor.record_deep_review_shared_context_tool_use(tool_name, input, context); - } - RuntimeHookKind::SuccessfulToolPostCall => {} - } - } + executor.record_deep_review_shared_context_tool_use(tool_name, input, context); } #[derive(Debug, Clone, Copy)] diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 9a974ebe4c..f863ed9d25 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -1814,6 +1814,7 @@ impl AgentRuntime { .create_session(AgentSessionCreateRequest { session_name, agent_type, + agent_route_key: None, workspace_path, project_workspace_path: None, execution_target: None, @@ -2729,6 +2730,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Fixed session".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some("/workspace/project".to_string()), project_workspace_path: None, execution_target: None, @@ -2760,6 +2762,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Fixed session".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some("/workspace/project".to_string()), project_workspace_path: None, execution_target: None, @@ -3363,6 +3366,7 @@ mod tests { .update_session_mode(AgentSessionModeUpdateRequest { session_id: "session_1".to_string(), mode_id: "plan".to_string(), + agent_route_key: None, }) .await .expect("update session mode"); @@ -3372,6 +3376,7 @@ mod tests { &[AgentSessionModeUpdateRequest { session_id: "session_1".to_string(), mode_id: "plan".to_string(), + agent_route_key: None, }] ); } @@ -3388,6 +3393,7 @@ mod tests { .update_session_mode(AgentSessionModeUpdateRequest { session_id: "session_1".to_string(), mode_id: "plan".to_string(), + agent_route_key: None, }) .await .unwrap_err(); diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index 94f66f785a..3390a9de56 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -243,6 +243,10 @@ pub struct SessionConfig { /// revalidated for every turn and never falls back by name alone. #[serde(default, skip_serializing_if = "is_local_agent_route_owner")] pub agent_route_owner: SessionAgentRouteOwner, + /// Stable identity of the selected Agent route. This is distinct from a + /// process-local generation key and survives plugin reloads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_route_key: Option, } fn deserialize_legacy_minimal_agent<'de, D>(deserializer: D) -> Result @@ -293,6 +297,7 @@ impl Default for SessionConfig { model_binding_fingerprint: None, prompt_cache_lineage_id: None, agent_route_owner: SessionAgentRouteOwner::Local, + agent_route_key: None, } } } @@ -475,24 +480,32 @@ mod tests { SessionModelBindingPolicy::Mutable ); assert_eq!(config.agent_route_owner, SessionAgentRouteOwner::Local); + assert!(config.agent_route_key.is_none()); } #[test] fn external_agent_route_owner_persists_and_legacy_sessions_default_local() { let config = SessionConfig { agent_route_owner: SessionAgentRouteOwner::External, + agent_route_key: Some("opencode:plugin:build".to_string()), ..SessionConfig::default() }; let mut serialized = serde_json::to_value(&config).expect("serialize session config"); assert_eq!(serialized["agent_route_owner"], "external"); + assert_eq!(serialized["agent_route_key"], "opencode:plugin:build"); serialized .as_object_mut() .expect("session config object") .remove("agent_route_owner"); + serialized + .as_object_mut() + .expect("session config object") + .remove("agent_route_key"); let restored: SessionConfig = serde_json::from_value(serialized).expect("deserialize legacy session config"); assert_eq!(restored.agent_route_owner, SessionAgentRouteOwner::Local); + assert!(restored.agent_route_key.is_none()); } #[test] diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs index 7667b7ecf7..fabc3bf105 100644 --- a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs @@ -1,8 +1,9 @@ //! Permission, question, and hook interaction contracts. -#![cfg(feature = "agent-runtime")] #[path = "agent_interaction_contracts/native_hook_payload_contracts.rs"] mod native_hook_payload_contracts; +#[path = "agent_interaction_contracts/native_hook_registry_contracts.rs"] +mod native_hook_registry_contracts; #[path = "agent_interaction_contracts/permission_contracts.rs"] mod permission_contracts; #[path = "agent_interaction_contracts/post_call_hook_contracts.rs"] diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_registry_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_registry_contracts.rs new file mode 100644 index 0000000000..eb7fc5b04c --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_registry_contracts.rs @@ -0,0 +1,262 @@ +use async_trait::async_trait; +use bitfun_agent_runtime::native_hooks::{ + AgentHookEngine, AgentHookMatcher, PluginHookCall, PluginHookExecutor, + PluginHookGenerationIdentity, PluginHookResult, RuntimeHookActivation, RuntimeHookKind, + RuntimeHookPlan, RuntimeHookRegistration, RuntimeHookRegistry, RuntimeHookSource, +}; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct FakePluginExecutor { + calls: Mutex>, +} + +#[async_trait] +impl PluginHookExecutor for FakePluginExecutor { + async fn execute(&self, mut call: PluginHookCall) -> Result { + self.calls.lock().unwrap().push(call.clone()); + call.output + .as_array_mut() + .expect("array output") + .push(serde_json::Value::String(call.instance_id.clone())); + Ok(PluginHookResult { + instance_id: call.instance_id, + generation_key: call.generation_key, + revision: call.revision, + hook_name: call.hook_name, + input: call.input, + output: call.output, + }) + } +} + +fn plugin_registration( + executor: Arc, + id: &str, + instance_id: &str, +) -> RuntimeHookRegistration { + plugin_registration_for_generation(executor, id, instance_id, "generation-1", "rev-1") +} + +fn plugin_registration_for_generation( + executor: Arc, + id: &str, + instance_id: &str, + generation_key: &str, + revision: &str, +) -> RuntimeHookRegistration { + RuntimeHookRegistration::plugin( + RuntimeHookPlan::new( + id, + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + RuntimeHookSource::Plugin, + ), + "tool.execute.before", + instance_id, + generation_key, + revision, + executor, + AgentHookMatcher::Any, + ) + .with_workspace_scope("C:/workspace") +} + +#[test] +fn activation_gate_hides_plugin_snapshot_until_ready() { + let registry = RuntimeHookRegistry::default(); + let token = registry + .register_plugin_batch(vec![plugin_registration( + Arc::new(FakePluginExecutor::default()), + "hook.a", + "plugin-a", + )]) + .unwrap(); + registry.set_source_activation(RuntimeHookSource::Plugin, RuntimeHookActivation::Preparing); + assert!(registry + .registrations_for_workspace( + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + Some("C:/workspace"), + ) + .is_empty()); + registry.activate_plugin_batch("C:/workspace", Some(&token)); + assert_eq!( + registry + .registrations_for_workspace( + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + Some("C:/workspace"), + ) + .len(), + 1 + ); +} + +#[test] +fn plugin_batch_commit_token_rolls_back_only_its_instance() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let token = registry + .register_plugin_batch(vec![plugin_registration( + executor.clone(), + "hook.a", + "plugin-a", + )]) + .unwrap(); + registry + .register_plugin_batch(vec![plugin_registration(executor, "hook.b", "plugin-b")]) + .unwrap(); + + registry.rollback_plugin_batch(&token); + + assert_eq!( + registry + .plans() + .iter() + .map(|plan| plan.id()) + .collect::>(), + vec!["hook.b"] + ); +} + +#[tokio::test] +async fn plugin_dispatch_invokes_each_handler_and_carries_output_forward() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let token = registry + .register_plugin_batch(vec![ + plugin_registration(executor.clone(), "hook-a", "plugin-a"), + plugin_registration(executor.clone(), "hook-b", "plugin-a"), + plugin_registration(executor.clone(), "hook-c", "plugin-a"), + ]) + .unwrap(); + registry.activate_plugin_batch("C:/workspace", Some(&token)); + + let result = AgentHookEngine::with_registry(registry) + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({"tool": "read"}), + serde_json::json!([]), + ) + .await; + + assert_eq!(result.executed_handlers, 3); + assert!(result.warnings.is_empty()); + assert_eq!( + result.output, + serde_json::json!(["plugin-a", "plugin-a", "plugin-a"]) + ); + let calls = executor.calls.lock().unwrap(); + assert_eq!(calls.len(), 3); + assert_eq!(calls[1].output, serde_json::json!(["plugin-a"])); + assert_eq!(calls[2].output, serde_json::json!(["plugin-a", "plugin-a"])); +} + +#[tokio::test] +async fn plugin_dispatch_isolated_by_canonical_workspace_scope() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let token_a = registry + .register_plugin_batch(vec![plugin_registration( + executor.clone(), + "hook-a", + "plugin-a", + )]) + .unwrap(); + let token_b = registry + .register_plugin_batch(vec![plugin_registration( + executor.clone(), + "hook-b", + "plugin-b", + ) + .with_workspace_scope("D:/workspace")]) + .unwrap(); + registry.activate_plugin_batch("C:/workspace", Some(&token_a)); + registry.activate_plugin_batch("D:/workspace", Some(&token_b)); + + let engine = AgentHookEngine::with_registry(registry); + let a = engine + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + let b = engine + .dispatch_plugin_hook( + Some("D:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + + assert_eq!(a.executed_handlers, 1); + assert_eq!(a.output, serde_json::json!(["plugin-a"])); + assert_eq!(b.executed_handlers, 1); + assert_eq!(b.output, serde_json::json!(["plugin-b"])); +} + +#[tokio::test] +async fn plugin_dispatch_uses_only_the_active_generation() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let old_token = registry + .register_plugin_batch(vec![plugin_registration_for_generation( + executor.clone(), + "hook-old", + "plugin-old", + "generation-old", + "rev-old", + )]) + .unwrap(); + let new_token = registry + .register_plugin_batch(vec![plugin_registration_for_generation( + executor, + "hook-new", + "plugin-new", + "generation-new", + "rev-new", + )]) + .unwrap(); + + registry.activate_plugin_batch("C:/workspace", Some(&old_token)); + let engine = AgentHookEngine::with_registry(registry.clone()); + let old = engine + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + assert_eq!(old.output, serde_json::json!(["plugin-old"])); + + registry.activate_plugin_batch("C:/workspace", Some(&new_token)); + let old_generation = PluginHookGenerationIdentity { + instance_id: "plugin-old".to_string(), + generation_key: "generation-old".to_string(), + revision: "rev-old".to_string(), + }; + let old_turn = engine + .dispatch_plugin_hook_for_generation( + Some("C:/workspace"), + Some(&old_generation), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + assert_eq!(old_turn.output, serde_json::json!(["plugin-old"])); + + registry.rollback_plugin_batch(&old_token); + let new = engine + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + assert_eq!(new.output, serde_json::json!(["plugin-new"])); +} diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs index bbdefba3e2..571cdc140d 100644 --- a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs @@ -1,48 +1,90 @@ -use bitfun_agent_runtime::post_call_hooks::{ - successful_tool_post_call_hooks, RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, - RuntimeHookRegistry, RuntimeHookRegistryBuildError, +use async_trait::async_trait; +use bitfun_agent_runtime::native_hooks::{ + AgentHookMatcher, BuiltinHookExecutor, HookCall, HookHandler, HookHandlerResult, + RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistration, + RuntimeHookRegistry, RuntimeHookRegistryBuildError, RuntimeHookSource, }; +use std::sync::Arc; + +struct NoopBuiltin; + +#[async_trait] +impl BuiltinHookExecutor for NoopBuiltin { + async fn execute(&self, _call: &HookCall) -> HookHandlerResult { + Default::default() + } +} + +fn builtin(plan: RuntimeHookPlan) -> RuntimeHookRegistration { + RuntimeHookRegistration::new( + plan, + HookHandler::Builtin { + executor: Arc::new(NoopBuiltin), + }, + AgentHookMatcher::Any, + ) +} + +fn plan(id: &str, source: RuntimeHookSource) -> RuntimeHookPlan { + RuntimeHookPlan::new(id, RuntimeHookKind::SuccessfulToolPostCall, source) +} #[test] -fn successful_tool_call_routes_to_shared_context_measurement_hook() { +fn successful_tool_call_uses_stable_builtin_registration_id() { + let registry = RuntimeHookRegistry::builder() + .register(builtin(plan( + "deep-review.shared-context", + RuntimeHookSource::Builtin { priority: 0 }, + ))) + .build() + .expect("builtin registration should build"); + + assert_eq!(registry.plans()[0].id(), "deep-review.shared-context"); assert_eq!( - successful_tool_post_call_hooks(), - [RuntimeHookKind::DeepReviewSharedContextToolUse] + registry.plans()[0].kind(), + &RuntimeHookKind::SuccessfulToolPostCall ); } #[test] -fn runtime_hook_registry_preserves_order_timeout_and_error_policy() { +fn runtime_hook_registry_preserves_source_order_timeout_and_error_policy() { let registry = RuntimeHookRegistry::builder() - .register( - RuntimeHookPlan::new( + .register(builtin( + plan("project.post-call", RuntimeHookSource::ProjectCommand) + .with_order(20) + .with_timeout_millis(750), + )) + .register(builtin( + plan( "deep-review.shared-context", - RuntimeHookKind::DeepReviewSharedContextToolUse, + RuntimeHookSource::Builtin { priority: 0 }, ) - .with_order(20) - .with_timeout_millis(750) - .with_error_policy(RuntimeHookErrorPolicy::RecordWarning), - ) - .register( - RuntimeHookPlan::new("audit.post-call", RuntimeHookKind::SuccessfulToolPostCall) + .with_order(20), + )) + .register(builtin( + plan("user.post-call", RuntimeHookSource::UserCommand) .with_order(10) .with_timeout_millis(250) .with_error_policy(RuntimeHookErrorPolicy::SkipHook), - ) + )) .build() .expect("hook registry should build"); assert_eq!( registry - .hooks() + .plans() .iter() .map(|hook| hook.id()) .collect::>(), - vec!["audit.post-call", "deep-review.shared-context"] + vec![ + "deep-review.shared-context", + "user.post-call", + "project.post-call" + ] ); - assert_eq!(registry.hooks()[0].timeout_millis(), 250); + assert_eq!(registry.plans()[1].timeout_millis(), 250); assert_eq!( - registry.hooks()[0].error_policy(), + registry.plans()[1].error_policy(), RuntimeHookErrorPolicy::SkipHook ); } @@ -50,14 +92,11 @@ fn runtime_hook_registry_preserves_order_timeout_and_error_policy() { #[test] fn runtime_hook_registry_rejects_duplicate_ids() { let error = RuntimeHookRegistry::builder() - .register(RuntimeHookPlan::new( - "duplicate", - RuntimeHookKind::SuccessfulToolPostCall, - )) - .register(RuntimeHookPlan::new( + .register(builtin(plan( "duplicate", - RuntimeHookKind::DeepReviewSharedContextToolUse, - )) + RuntimeHookSource::Builtin { priority: 0 }, + ))) + .register(builtin(plan("duplicate", RuntimeHookSource::UserCommand))) .build() .expect_err("duplicate hook ids must not be silently accepted"); @@ -72,26 +111,24 @@ fn runtime_hook_registry_rejects_duplicate_ids() { #[test] fn runtime_hook_registry_rejects_unstable_ids_and_zero_timeouts() { let empty_id_error = RuntimeHookRegistry::builder() - .register(RuntimeHookPlan::new( + .register(builtin(plan( " ", - RuntimeHookKind::SuccessfulToolPostCall, - )) + RuntimeHookSource::Builtin { priority: 0 }, + ))) .build() .expect_err("blank hook ids must not become registry keys"); - assert_eq!(empty_id_error, RuntimeHookRegistryBuildError::EmptyHookId); let zero_timeout_error = RuntimeHookRegistry::builder() - .register( - RuntimeHookPlan::new( + .register(builtin( + plan( "deep-review.shared-context", - RuntimeHookKind::DeepReviewSharedContextToolUse, + RuntimeHookSource::Builtin { priority: 0 }, ) .with_timeout_millis(0), - ) + )) .build() .expect_err("hook timeouts must remain explicit and non-zero"); - assert_eq!( zero_timeout_error, RuntimeHookRegistryBuildError::InvalidTimeoutMillis { diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs index a5b681cb77..a145a58694 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs @@ -2,6 +2,10 @@ use std::path::Path; use std::sync::{Arc, Mutex}; use async_trait::async_trait; +use bitfun_agent_runtime::native_hooks::{ + AgentHookMatcher, BuiltinHookExecutor, HookCall, HookHandler, HookHandlerResult, + RuntimeHookRegistration, RuntimeHookSource, +}; use bitfun_agent_runtime::sdk::{ AgentEventStream, AgentModeCatalogEntry, AgentModeCatalogPort, AgentModeCatalogQuery, AgentRunRequest, AgentRuntimeBuilder, AgentRuntimeSdkCompatibility, AgentRuntimeSdkStability, @@ -26,6 +30,15 @@ struct FakeSdkAgentProvider { struct FakeSdkTool; +struct NoopHook; + +#[async_trait] +impl BuiltinHookExecutor for NoopHook { + async fn execute(&self, _call: &HookCall) -> HookHandlerResult { + Default::default() + } +} + #[derive(Debug)] struct FakeSdkAgentRegistry { agent_ids: Vec, @@ -63,6 +76,7 @@ impl AgentModeCatalogPort for FakeModeCatalog { self.queries.lock().unwrap().push(query); Ok(vec![AgentModeCatalogEntry { id: "Explore".to_string(), + route_key: "Explore".to_string(), description: "Inspect the workspace".to_string(), model_id: Some("model-a".to_string()), is_external: false, @@ -374,11 +388,19 @@ async fn sdk_facade_accepts_fake_services_tools_and_hooks_without_core() { let mut tools = ToolRegistry::new(); tools.register_tool(Arc::new(FakeSdkTool)); let hooks = RuntimeHookRegistry::builder() - .register( - RuntimeHookPlan::new("sdk.post_call", RuntimeHookKind::SuccessfulToolPostCall) - .with_timeout_millis(250) - .with_error_policy(RuntimeHookErrorPolicy::RecordWarning), - ) + .register(RuntimeHookRegistration::new( + RuntimeHookPlan::new( + "sdk.post_call", + RuntimeHookKind::SuccessfulToolPostCall, + RuntimeHookSource::Builtin { priority: 0 }, + ) + .with_timeout_millis(250) + .with_error_policy(RuntimeHookErrorPolicy::RecordWarning), + HookHandler::Builtin { + executor: Arc::new(NoopHook), + }, + AgentHookMatcher::Any, + )) .build() .expect("hook registry should build"); diff --git a/src/crates/interfaces/acp/src/runtime/session.rs b/src/crates/interfaces/acp/src/runtime/session.rs index 9488411c6b..2301e00d01 100644 --- a/src/crates/interfaces/acp/src/runtime/session.rs +++ b/src/crates/interfaces/acp/src/runtime/session.rs @@ -57,6 +57,7 @@ impl BitfunAcpRuntime { chrono::Local::now().format("%Y-%m-%d %H:%M:%S") ), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(cwd.clone()), project_workspace_path: None, execution_target: None, @@ -510,6 +511,7 @@ impl BitfunAcpRuntime { .update_session_mode(AgentSessionModeUpdateRequest { session_id: session.bitfun_session_id.clone(), mode_id: mode_id.to_string(), + agent_route_key: None, }) .await .map_err(|error| Self::session_runtime_error(&session.acp_session_id, error))?; diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs index eec63088f1..1165da8950 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs @@ -42,6 +42,8 @@ pub struct ListAgentModesResponse { #[serde(rename_all = "camelCase")] pub struct AgentModeSummary { pub id: String, + #[serde(default)] + pub route_key: String, pub description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, diff --git a/src/crates/interfaces/app-server/src/management/owner.rs b/src/crates/interfaces/app-server/src/management/owner.rs index 6cb2a8f45b..52dad2b336 100644 --- a/src/crates/interfaces/app-server/src/management/owner.rs +++ b/src/crates/interfaces/app-server/src/management/owner.rs @@ -1184,6 +1184,7 @@ impl AppManagementService { .into_iter() .map(|mode| AgentModeSummary { id: mode.id, + route_key: mode.key, description: mode.description, model_id: mode.model, is_external: mode.source == bitfun_core::agentic::agents::AgentSource::External, diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index 6c95f33c1b..2551afe643 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -301,6 +301,7 @@ struct Phase2Provider { steers: Mutex>, shell_commands: Mutex>, answers: Mutex>, + local_commands: Mutex>, compactions: Mutex>, settlements: Mutex>, reloads: Mutex>, @@ -467,6 +468,7 @@ impl ports::AgentLocalCommandTurnPort for Phase2Provider { &self, request: ports::AgentLocalCommandTurnRecordRequest, ) -> PortResult { + self.local_commands.lock().unwrap().push(request.clone()); Ok(ports::AgentLocalCommandTurnRecordResult { turn_id: request .turn_id @@ -653,11 +655,6 @@ fn revert_result(session_id: String, text: &str) -> ports::AgentSessionRevertRes retired_turn_ids: vec!["turn-active".to_string()], changed: true, hidden_turn_count: 1, - boundary_storage_turn_index: None, - target_turn_id: None, - restored_files: Vec::new(), - reload_required: false, - reload_reason: None, } } @@ -841,8 +838,6 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { turn_id: "turn-active".to_string(), content: "keep going".to_string(), display_content: None, - attachments: Vec::new(), - metadata: serde_json::Map::new(), }, )) .await @@ -868,6 +863,20 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { }) .await .expect("submit user answers"); + let local_turn = client + .record_local_command_turn(protocol_session::RecordLocalCommandTurnRequest( + ports::AgentLocalCommandTurnRecordRequest { + session_id: "session-1".to_string(), + content: "usage: 12 tokens".to_string(), + turn_id: Some("local-turn".to_string()), + timestamp_ms: Some(100), + metadata: serde_json::Map::new(), + }, + )) + .await + .expect("record local command turn"); + assert_eq!(local_turn.0.turn_id, "local-turn"); + client .compact_session(protocol_session::CompactSessionRequest( ports::AgentSessionCompactionRequest { @@ -916,6 +925,7 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { "cargo test" ); assert_eq!(provider.answers.lock().unwrap().len(), 1); + assert_eq!(provider.local_commands.lock().unwrap().len(), 1); assert_eq!(provider.compactions.lock().unwrap().len(), 1); assert_eq!(provider.reloads.lock().unwrap().len(), 1); client.shutdown().await; @@ -1197,6 +1207,7 @@ async fn session_control_methods_forward_exact_owner_dtos() { UpdateSessionModeMessage(AgentSessionModeUpdateRequest { session_id: "session-1".to_string(), mode_id: "plan".to_string(), + agent_route_key: None, }), )) .await?; @@ -1386,6 +1397,7 @@ async fn create_session_returns_provider_session_id() { AgentSessionCreateRequest { session_name: "direct create".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: None, project_workspace_path: None, execution_target: None, diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index afba40f55e..4a22381572 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -1033,6 +1033,7 @@ impl SdkHostConnection { .session_name .unwrap_or_else(|| DEFAULT_SESSION_NAME.to_string()), agent_type: params.agent.unwrap_or_else(|| DEFAULT_AGENT.to_string()), + agent_route_key: None, workspace_path: Some(workspace_path.clone()), project_workspace_path: None, execution_target: None, @@ -1251,6 +1252,7 @@ impl SdkHostConnection { .agent .clone() .unwrap_or_else(|| DEFAULT_AGENT.to_string()), + agent_route_key: None, workspace_path: Some(workspace_path.clone()), project_workspace_path: None, execution_target: None, diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 7ea9583c34..f322e1d380 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -126,6 +126,7 @@ pub fn build_remote_session_create_request( AgentSessionCreateRequest { session_name: session_name.into(), agent_type: agent_type.into(), + agent_route_key: None, workspace_path: workspace_path.map(Into::into), project_workspace_path: None, execution_target: None, diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 9fe769c43a..7f27f69f5c 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -615,9 +615,7 @@ describe('startup performance contract', () => { expect(chatInputSource).toContain('getHistorySessionOpenTransitionSnapshot'); expect(chatInputSource).toContain('deferChatStripPassiveGitRefresh'); expect(chatInputSource).toContain('historySessionOpenTransition !== null'); - expect(fileCardSource).toContain('getHistorySessionOpenTransitionSnapshot'); - expect(fileCardSource).toContain('historySessionOpenTransition === null'); - expect(fileCardSource).toContain("displayContext !== 'subagent-projection'"); + expect(fileCardSource).not.toContain('useGitState'); expect(workspaceItemSource).toContain('getHistorySessionOpenTransitionSnapshot'); expect(workspaceItemSource).toContain('suppressWorkspaceGitRefreshOnMountDuringSessionTransition'); expect(workspaceItemSource).toContain('subscribeHistorySessionOpenTransition'); diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx index 80fa8ed87d..32bd9aec88 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx @@ -68,6 +68,9 @@ vi.mock('@bitfun/ui', () => ({ IconButton: ({ children, ...props }: React.ButtonHTMLAttributes) => ( ), + Field: ({ label, children }: React.PropsWithChildren<{ label: string }>) => ( + + ), Input: ({ leading, trailing, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 626704ffcc..1afdddc0ca 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -3,6 +3,7 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { computeFlowChatInputStackFooterPx } from '../../utils/flowChatScrollLayout'; import { tailSpacerPxForViewport } from './flowChatTailFollow'; import { ONE_SHOT_NAVIGATION_HOLD_MS } from './flowChatViewportOwnership'; import { VirtualMessageList, type VirtualMessageListRef } from './VirtualMessageList'; @@ -46,8 +47,8 @@ const mocks = vi.hoisted(() => ({ renderItemMetadata: true, })); -/** Input-stack footer the chat-input mock produces: 140 + 4 + 24. */ -const BOTTOM_INSET = 168; +/** Input-stack footer produced by the mocked 140px composer. */ +const BOTTOM_INSET = computeFlowChatInputStackFooterPx(140); /** * jsdom has no layout engine, so both halves of the navigation clamp have to be @@ -357,8 +358,8 @@ describe('VirtualMessageList natural scroll contract', () => { it('renders only the current input layout inset in the Footer', () => { act(() => root.render()); const footer = container.querySelector('.message-list-footer'); - expect(footer?.style.height).toBe('168px'); - expect(footer?.style.minHeight).toBe('168px'); + expect(footer?.style.height).toBe(`${BOTTOM_INSET}px`); + expect(footer?.style.minHeight).toBe(`${BOTTOM_INSET}px`); }); it('reserves a tail spacer from the viewport and input-stack inset', () => { @@ -386,7 +387,7 @@ describe('VirtualMessageList natural scroll contract', () => { // The input-stack footer stays a separate reservation. It feeds the // spacer's size, but the two are never folded into one number. expect(container.querySelector('.message-list-footer')?.style.height) - .toBe('168px'); + .toBe(`${BOTTOM_INSET}px`); } finally { if (originalClientHeight) { Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight); @@ -497,7 +498,7 @@ describe('VirtualMessageList natural scroll contract', () => { owner: 'one-shot-navigation', holdForMs: ONE_SHOT_NAVIGATION_HOLD_MS, }); - expect(container.querySelector('.message-list-footer')?.getAttribute('style')).toContain('168px'); + expect(container.querySelector('.message-list-footer')?.getAttribute('style')).toContain(`${BOTTOM_INSET}px`); }); it('top-aligns a Turn that still has a transcript below it', () => { @@ -1089,7 +1090,7 @@ describe('VirtualMessageList natural scroll contract', () => { const listRef = React.createRef(); act(() => root.render()); expect(listRef.current?.prepareTurnNavigation('turn-2')).toBe('pending'); - expect(container.querySelector('.message-list-footer')?.getAttribute('style')).toContain('168px'); + expect(container.querySelector('.message-list-footer')?.getAttribute('style')).toContain(`${BOTTOM_INSET}px`); }); it('captures and restores a history viewport by Turn and viewport offset', () => { diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.test.tsx index 442f5449aa..dcf7defb8f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.test.tsx @@ -219,7 +219,7 @@ describe('CodeReviewToolCard', () => { ); }); act(() => { - container.querySelector('.preview-toggle-btn')?.dispatchEvent( + container.querySelector('[data-bf-part="affordanceButton"]')?.dispatchEvent( new window.Event('click', { bubbles: true }), ); }); @@ -293,7 +293,7 @@ describe('CodeReviewToolCard', () => { ); }); act(() => { - container.querySelector('.preview-toggle-btn')?.dispatchEvent( + container.querySelector('[data-bf-part="affordanceButton"]')?.dispatchEvent( new window.Event('click', { bubbles: true }), ); }); @@ -366,7 +366,7 @@ describe('CodeReviewToolCard', () => { ); }); act(() => { - container.querySelector('.preview-toggle-btn')?.dispatchEvent( + container.querySelector('[data-bf-part="affordanceButton"]')?.dispatchEvent( new window.Event('click', { bubbles: true }), ); }); @@ -429,7 +429,7 @@ describe('CodeReviewToolCard', () => { }); act(() => { - container.querySelector('.preview-toggle-btn')?.dispatchEvent( + container.querySelector('[data-bf-part="affordanceButton"]')?.dispatchEvent( new window.Event('click', { bubbles: true }), ); }); @@ -491,7 +491,7 @@ describe('CodeReviewToolCard', () => { }); act(() => { - container.querySelector('.preview-toggle-btn')?.dispatchEvent( + container.querySelector('[data-bf-part="affordanceButton"]')?.dispatchEvent( new window.Event('click', { bubbles: true }), ); }); @@ -545,7 +545,7 @@ describe('CodeReviewToolCard', () => { ); }); act(() => { - container.querySelector('.preview-toggle-btn')?.dispatchEvent( + container.querySelector('[data-bf-part="affordanceButton"]')?.dispatchEvent( new window.Event('click', { bubbles: true }), ); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx index 455f49bfc4..bb6c9e1d21 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx @@ -157,10 +157,6 @@ describe('FileOperationToolCard', () => { mocks.inlineDiffPreviewProps = []; mocks.typewriterMode = 'passthrough'; mocks.writePlanDisplayProps = []; - mocks.useGitState.mockClear(); - mocks.useGitState.mockReturnValue({ - isRepository: false, - }); mocks.getOperationDiff.mockReset(); mocks.getOperationDiff.mockResolvedValue({ originalContent: '', @@ -317,111 +313,6 @@ describe('FileOperationToolCard', () => { expect(container.querySelector('[data-testid="chat-file-change-card"]')).not.toBeNull(); }); - it('does not trigger passive git refresh while historical restore is pending', async () => { - mocks.currentWorkspace = { rootPath: 'D:/workspace/BitFun' }; - const toolItem: FlowToolItem = { - id: 'tool-history', - type: 'tool', - toolName: 'Write', - status: 'completed', - toolCall: { - id: 'call-history', - name: 'Write', - input: { - file_path: 'src/newFile.ts', - content: 'export const value = 1;', - }, - }, - toolResult: { - success: true, - result: { - file_path: 'src/newFile.ts', - }, - }, - } as FlowToolItem; - - await act(async () => { - root.render( - - - - ); - }); - - expect(mocks.useGitState).toHaveBeenCalledWith(expect.objectContaining({ - repositoryPath: 'D:/workspace/BitFun', - isActive: false, - refreshOnMount: false, - refreshOnActive: false, - participateInWindowFocusRefresh: false, - layers: ['basic'], - })); - }); - - it('keeps passive git refresh enabled for normal active sessions', async () => { - mocks.currentWorkspace = { rootPath: 'D:/workspace/BitFun' }; - const toolItem: FlowToolItem = { - id: 'tool-active', - type: 'tool', - toolName: 'Write', - status: 'completed', - toolCall: { - id: 'call-active', - name: 'Write', - input: { - file_path: 'src/newFile.ts', - content: 'export const value = 1;', - }, - }, - toolResult: { - success: true, - result: { - file_path: 'src/newFile.ts', - }, - }, - } as FlowToolItem; - - await act(async () => { - root.render( - - - - ); - }); - - expect(mocks.useGitState).toHaveBeenCalledWith(expect.objectContaining({ - repositoryPath: 'D:/workspace/BitFun', - isActive: true, - refreshOnMount: true, - refreshOnActive: false, - })); - }); - afterEach(() => { act(() => { root.unmount(); diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 2d76428d5c..866201b952 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -53,6 +53,7 @@ vi.mock('@bitfun/ui', () => ({ onKeyDown={event => event.key === 'Enter' && onSearch(event.currentTarget.value)} /> ), + Input: (props: React.InputHTMLAttributes) => , Select: ({ options, onValueChange, ...props }: any) => (