From af162a114671c0baa1c64f48bf84122790085152 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 29 Aug 2026 09:44:41 +0800 Subject: [PATCH 1/2] refactor(extensions): move OpenCode config projection into adapter --- Cargo.lock | 1 + .../opencode-extension-compatibility.md | 8 +- .../core-boundaries/rules/feature-rules.mjs | 2 +- .../rules/source/forbidden-rules.mjs | 1 + .../rules/source/public-api-rules.mjs | 28 +- scripts/core-boundaries/self-test.mjs | 4 +- .../adapters/opencode-adapter/AGENTS-CN.md | 20 +- .../adapters/opencode-adapter/AGENTS.md | 17 +- .../adapters/opencode-adapter/Cargo.toml | 3 +- .../adapters/opencode-adapter/src/lib.rs | 6 + .../src/plugin_config_projection.rs | 1229 +++++++++++++ .../tools/implementations/skills/registry.rs | 2 +- .../assembly/core/src/external_tools.rs | 2 +- src/crates/assembly/core/src/lib.rs | 2 +- src/crates/assembly/core/src/native_hooks.rs | 4 +- .../core/src/plugin_config_projection.rs | 1590 ----------------- .../core/src/plugin_config_publication.rs | 472 +++++ src/crates/assembly/core/src/plugin_host.rs | 32 +- 18 files changed, 1792 insertions(+), 1631 deletions(-) create mode 100644 src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs delete mode 100644 src/crates/assembly/core/src/plugin_config_projection.rs create mode 100644 src/crates/assembly/core/src/plugin_config_publication.rs diff --git a/Cargo.lock b/Cargo.lock index b53a62172d..f3e4edef84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1277,6 +1277,7 @@ dependencies = [ "dunce", "globset", "hex", + "log", "oxc", "regex", "serde", diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 845bc53ba7..237994a815 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -110,8 +110,10 @@ Tool 执行与取消、dispose,以及插件反向调用的 metadata/ask。Open 失败会停止当前有序 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 链, +合并完整 JSON/JSONC 对象;`$schema` 不是必填项,未知字段原样保留。OpenCode adapter 负责 Config contributor 归属及 +Agent/权限/Plugin Tool/Skill 字段的类型化投影;Core 只负责现有产品 owner 对接、runtime key 绑定和 generation 原子提交。 +Config Hook、插件 Agent/权限/Skill 投影、Tool 注册和模型可见 output 已接入现有归属模块。插件 Tool 的真实 +`title`、`output`、`metadata` 会进入完整 after Hook 链, 原始结构化结果继续保留这些字段;after Hook 对 title/metadata 的变换尚无稳定 UI/持久化消费方,当前只把变换后的 output 作为模型展示结果,后续有真实消费方时再扩展小型展示契约。 @@ -386,7 +388,7 @@ Node 进程永久累积;这不是 package-plugin 的 workspace-scoped runtime | 代际替换与长时 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。 | +| Core 内遗留的 OpenCode Client wire 投影 | 后续边界收敛 PR | 当前 loopback adapter 已拥有认证、framing、route/method 匹配和传输错误,但受限的 `client.*` bridge 仍在 Core 解析部分 query/body 并生成 wire JSON;普通插件链路已闭环,协议演进仍可能触及 Product Assembly | 按真实插件消费到的 route 分批把 wire DTO、解析和响应投影移入 `opencode-plugin-host`,向 Core 暴露最小 typed operation/result 并增加 boundary gate;不引入通用 HTTP transport、第二套产品协议或一次性重写。 | | 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/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index d6dea312f3..d9c5e96e8c 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -449,7 +449,7 @@ export const capabilityContractDependencyRules = [ capabilityEdge(['agent-api', 'permission', 'workspace-ports']), ])], ['bitfun-opencode-adapter', capabilityConsumer([ - capabilityEdge(['plugin-runtime']), + capabilityEdge(['hook-function-runtime', 'plugin-runtime']), capabilityEdge(['script-tool-runtime'], { kind: 'dev' }), ])], ['bitfun-opencode-plugin-host', capabilityConsumer([ diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index dd7f7068b5..5a83fc886b 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4150,6 +4150,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', + 'src/crates/assembly/core/src/plugin_config_publication.rs', ], message: 'only a reviewed product composition root may import bitfun-opencode-adapter through a capability-specific provider boundary', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 2b5f785545..bc7ab649c4 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -243,6 +243,23 @@ function opencodeHookAdapterEntry(symbol, consumer) { }; } +function opencodePluginConfigProjectionEntry(symbol) { + return { + symbol, + owner: 'opencode-adapter managed Plugin Host Config projection owner', + consumer: 'bitfun-core plugin Config publication composition root', + verification: + 'OpenCode plugin Config projection tests, bitfun-core publication tests, and core-boundary public API budget checks', + p0: 'typed OpenCode Config Hook projection for the managed Plugin Host runtime slice', + contractSlice: contractSlices.opencodeAdapterBoundary, + wireImpact: false, + rationale: + 'the live Plugin Host path needs OpenCode field interpretation and contributor attribution outside Product Assembly while Core retains product publication', + exit: + 'remove only if the managed Plugin Host Config projection moves behind another reviewed OpenCode adapter boundary with equivalent attribution and fail-closed tests', + }; +} + function opencodeReferenceAdapterEntry(symbol, consumer) { return { symbol, @@ -277,6 +294,15 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeConfigSnapshotError', 'bitfun-core live Plugin Host config validation and OpenCode config snapshot contract tests', ), + ...[ + 'project_plugin_config', + 'OpenCodePluginAgentProjection', + 'OpenCodePluginConfigProjection', + 'OpenCodePluginConfigProjectionError', + 'OpenCodePluginContributor', + 'OpenCodePluginSkillRootProjection', + 'OpenCodePluginToolRef', + ].map(opencodePluginConfigProjectionEntry), opencodeAdapterEntry( 'OpenCodeCommandProvider', 'bitfun-core external source composition root and OpenCode command adapter tests', @@ -1339,7 +1365,7 @@ export const publicApiAllowlistRules = [ { path: 'src/crates/adapters/opencode-adapter/src/lib.rs', reason: - 'OpenCode adapter public API must stay limited to reviewed runtime-free sources and the PluginRuntimeClient adapter boundary', + 'OpenCode adapter public API must stay limited to reviewed sources, managed Plugin Host Config projection, and the PluginRuntimeClient adapter boundary', allowedSymbolEntries: opencodeAdapterPublicApiEntries, }, { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 3179a8e229..5180fbe750 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1604,10 +1604,10 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - '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' + 'load_opencode_package_adapter,load_opencode_config_snapshot,OpenCodeConfigSnapshot,OpenCodeConfigSnapshotError,project_plugin_config,OpenCodePluginAgentProjection,OpenCodePluginConfigProjection,OpenCodePluginConfigProjectionError,OpenCodePluginContributor,OpenCodePluginSkillRootProjection,OpenCodePluginToolRef,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', + 'OpenCode adapter public API budget must stay limited to the reviewed package factory, managed Plugin Host Config projection, and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, workspace Reference, and user Instruction providers', ); } const opencodeInstructionSymbols = new Set([ diff --git a/src/crates/adapters/opencode-adapter/AGENTS-CN.md b/src/crates/adapters/opencode-adapter/AGENTS-CN.md index 585d7769da..a08ca6d55c 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS-CN.md +++ b/src/crates/adapters/opencode-adapter/AGENTS-CN.md @@ -2,8 +2,9 @@ # OpenCode Adapter -当前 crate 负责现有受管包路径使用的 P0 OpenCode 静态来源预览,以及 Command、standalone Tool 和 Subagent -能力专属 provider 契约的 OpenCode 实现。它保留 OpenCode 来源发现、优先级、格式、参数展开和版本化兼容语义。 +当前 crate 负责现有受管包路径使用的 P0 OpenCode 静态来源预览、受管 Plugin Host Config Hook 结果的类型化 +Agent/权限/Plugin Tool/Skill 投影,以及 Command、standalone Tool 和 Subagent 能力专属 provider 契约的 OpenCode +实现。它保留 OpenCode 来源发现、优先级、格式、参数展开和版本化兼容语义。 共享来源目录、生命周期协调、 文件观察实现、产品策略、界面、凭据、worker 监督和最终结果写入均由其他 owner 负责。 @@ -21,7 +22,8 @@ 物理健康按实际进程分组,不默认按全局或 workspace 建键。跨项目本身 不重复询问,只有运行条件、凭据或能力扩大时确认。 - `ExternalSourceControlPlane` 负责候选版本,并通过现有来源协调模块替换 provider;本 adapter 通过窄 provider 契约提供 OpenCode 限定的来源 - 身份/顺序和观察根,可复用文件观察服务只提供变化事实。配置归属模块提供规范化配置快照,`ScriptToolRuntime` + 身份/顺序和观察根,可复用文件观察服务只提供变化事实。本 crate 提供规范化配置快照和 Config contributor + 类型化投影,Core 保留逻辑 workspace generation 与能力原子发布;`opencode-plugin-host` 负责 Host wire/连接,`ScriptToolRuntime` 所在的 services 实现拥有依赖、worker、进程树和物理健康;`PluginRuntimeClient` 当前只拥有请求可靠性、诊断和 故障诊断,并消费既有归属模块提供的生命周期事实;贡献注册仍由对应能力归属模块提交。 - 第三方模块 import 前必须依据来源、插件身份、实际执行域/用户、产品/组织策略上限、凭据范围和环境范围重新计算 @@ -35,8 +37,10 @@ - 依赖 `bitfun-runtime-ports` 等稳定接口和 `PluginRuntimeAdapter` 边界 trait,不依赖 `bitfun-core`、app crate、Tauri API、产品界面或具体服务管理器。 -- OpenCode 配置 JSON、来源顺序、加载器兼容和参数展开保留在本 crate 内。跨 crate 输出使用类型化来源快照、 - adapter 绑定和 `PluginRuntimeClient` DTO,不得把 OpenCode 原始 JSON 或源码语法暴露为产品接口。 +- OpenCode 配置 JSON、来源顺序、加载器兼容、参数展开和受管 Plugin Host Config 贡献归属保留在本 crate 内。 + 跨 crate 输出使用类型化来源快照、Agent/权限/Plugin Tool/Skill 投影、adapter 绑定和 `PluginRuntimeClient` DTO, + 不得把 OpenCode 原始 JSON 或源码语法暴露为产品接口。Config 投影不得生成 Agent runtime key、选择原生 Tool + 基线、修改产品 registry 或扩成通用 Config 平台。 - 当前源码探测只识别测试覆盖的声明式语法子集,不是通用 JS/TS 解析器;没有可识别入口的包和已识别但不支持的 hook 必须返回诊断,其他语法不属于当前兼容范围。 - 未支持的 OpenCode 能力必须显式返回类型化诊断或不支持状态,不得静默忽略。 @@ -44,8 +48,9 @@ OpenCode JSON 访问,也不得只为目标设计完整性增加 API。 - 经评审的产品组装根只选择并构造已编译的 OpenCode adapter/provider,再注入 `PluginRuntimeClient`;它不发现 动态来源、不准备依赖,也不 import 插件模块。 -- Product Assembly 只允许从经过评审的组装模块(如 `bitfun-core/plugin_runtime` 或 - `bitfun-core/external_sources`)消费本 crate;增加其他消费方时必须同步边界脚本和聚焦组装路径测试。 +- Product Assembly 只允许从经过评审的组装模块(如 `bitfun-core/plugin_runtime`、 + `bitfun-core/external_sources` 或 `bitfun-core/plugin_config_publication`)消费本 crate;增加其他消费方时必须同步 + 边界脚本和聚焦组装路径测试。 - 本 crate 不得依赖 Codex、Claude Code 或其他生态 adapter。新生态是由 Product Assembly 注册的同级 adapter, 不是本 adapter 的模式。 - 生产 crate 不得直接依赖 `bitfun_opencode_adapter` 内部类型。未支持能力必须诊断化, @@ -59,5 +64,6 @@ - `cargo test -p bitfun-opencode-adapter --test opencode_static_source_contracts opencode_subagent_adapter::` - `cargo test -p bitfun-opencode-adapter --test tool_source_contracts` - `cargo test -p bitfun-opencode-adapter --lib p0_c2_fixture` +- `cargo test -p bitfun-opencode-adapter --lib plugin_config_projection` - `cargo test -p bitfun-opencode-adapter --lib client_path_projects_trusted_custom_tool_candidate_with_permission_prompt` - `node scripts/check-core-boundaries.mjs` diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index d311bf029d..42f2ebe56d 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -6,7 +6,8 @@ The current crate owns OpenCode user Instruction path/config precedence, the sta managed-package path, the OpenCode-specific implementations of command, standalone-tool, subagent, and MCP provider contracts, the bounded projection of configured local Skill roots, the private merged configuration snapshot passed -to the managed Plugin Host's Config Hook, and runtime-free mapping of +to the managed Plugin Host's Config Hook, the typed Agent/permission/Tool/Skill +projection of that Hook's result, 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, @@ -41,8 +42,9 @@ Product-source boundary: replacement. This adapter supplies OpenCode-qualified source identity/order and watch roots through narrow provider contracts; the reusable file-watch service 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 + workspace generations and capability publication, this crate owns the typed + Config projection, and the sibling `opencode-plugin-host` crate owns the typed + Host wire/connection while using 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 @@ -80,6 +82,10 @@ Product-source boundary: 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. +- Managed Plugin Host Config projection validates contributor attribution and + maps only the currently consumed Agent, permission, Plugin Tool, and workspace + Skill fields. It must not create Agent runtime keys, select native Tool + baselines, mutate product registries, or grow into a generic Config platform. - 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 @@ -106,8 +112,8 @@ Product-source boundary: import the adapter directly. The composition layer does not discover dynamic sources, prepare dependencies, or import plugin modules. - Product Assembly may consume this crate only from reviewed composition modules - such as `bitfun-core/plugin_runtime`, `bitfun-core/external_sources`, or - `bitfun-core/instruction_sources`; boundary + such as `bitfun-core/plugin_runtime`, `bitfun-core/external_sources`, + `bitfun-core/instruction_sources`, or `bitfun-core/plugin_config_publication`; boundary guards and focused assembly-path tests must change with any additional consumer. - This crate must not depend on Codex, Claude Code, or another ecosystem adapter. New ecosystems are sibling adapters registered by Product Assembly, not modes of @@ -124,5 +130,6 @@ Product-source boundary: - `cargo test -p bitfun-opencode-adapter --test opencode_static_source_contracts opencode_subagent_adapter::` - `cargo test -p bitfun-opencode-adapter --test tool_source_contracts` - `cargo test -p bitfun-opencode-adapter --lib p0_c2_fixture` +- `cargo test -p bitfun-opencode-adapter --lib plugin_config_projection` - `cargo test -p bitfun-opencode-adapter --lib client_path_projects_trusted_custom_tool_candidate_with_permission_prompt` - `node scripts/check-core-boundaries.mjs` diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index 0dbcc24215..00b895ff0e 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -14,13 +14,14 @@ crate-type = ["rlib"] async-trait = { workspace = true } bitfun-plugin-runtime-client = { path = "../../execution/plugin-runtime-client" } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["external-sources", "plugin-source"] } -bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["plugin-runtime"] } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["hook-function-runtime", "plugin-runtime"] } bitfun-services-core = { path = "../../services/services-core", features = ["markdown"] } bitfun-static-hook-support = { path = "../static-hook-support" } dirs = { workspace = true } dunce = { workspace = true } hex = { workspace = true } globset = { workspace = true } +log = { workspace = true } oxc-parse = { workspace = true } regex = { workspace = true } serde = { workspace = true } diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index 6c7cdfa5f3..f221600fab 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -13,6 +13,7 @@ mod hook_source; mod instruction_source; mod local_source_paths; mod mcp_source; +mod plugin_config_projection; mod reference_source; mod skill_source; mod source_adapter; @@ -23,6 +24,11 @@ pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions pub use hook_source::{OpenCodeHookProvider, OpenCodeHookProviderOptions}; pub use instruction_source::{load_opencode_user_instructions, OpenCodeInstructionSourceOptions}; pub use mcp_source::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; +pub use plugin_config_projection::{ + project_plugin_config, OpenCodePluginAgentProjection, OpenCodePluginConfigProjection, + OpenCodePluginConfigProjectionError, OpenCodePluginContributor, + OpenCodePluginSkillRootProjection, OpenCodePluginToolRef, +}; pub use reference_source::{ OpenCodeWorkspaceReferenceProvider, OpenCodeWorkspaceReferenceProviderOptions, }; diff --git a/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs b/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs new file mode 100644 index 0000000000..8693da43d6 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs @@ -0,0 +1,1229 @@ +use bitfun_product_domains::external_subagents::ExternalSubagentMode; +use bitfun_product_domains::tool_permissions::{ + PermissionConstraintLayer, PermissionEffect, PermissionRule, +}; +use bitfun_runtime_ports::{ + HookFunctionContributorOutcome, HookFunctionPluginIdentity, HookFunctionRegistrationBatch, + HookFunctionToolRegistration, +}; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +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; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct OpenCodePluginContributor { + id: Option, + spec: String, + entry: String, + index: usize, + stable_key: String, + label: String, +} + +impl OpenCodePluginContributor { + pub fn stable_key(&self) -> &str { + &self.stable_key + } + + pub fn label(&self) -> &str { + &self.label + } +} + +impl From<&HookFunctionPluginIdentity> for OpenCodePluginContributor { + fn from(value: &HookFunctionPluginIdentity) -> Self { + Self { + id: value.id.clone(), + spec: value.spec.clone(), + entry: value.entry.clone(), + index: value.index, + stable_key: format!("{}\n{}\n{}", value.spec, value.entry, value.index), + label: value.id.clone().unwrap_or_else(|| value.spec.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct OpenCodePluginToolRef { + pub contributor: OpenCodePluginContributor, + pub id: String, +} + +impl TryFrom<&HookFunctionToolRegistration> for OpenCodePluginToolRef { + type Error = OpenCodePluginConfigProjectionError; + + fn try_from(tool: &HookFunctionToolRegistration) -> Result { + let contributor = tool + .plugin + .as_ref() + .map(OpenCodePluginContributor::from) + .ok_or_else(|| contribution_error("Plugin tool identity is missing"))?; + validate_plugin_identity(&contributor)?; + validate_tool_id(&tool.id)?; + Ok(Self { + contributor, + id: tool.id.clone(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct OpenCodePluginAgentProjection { + pub contributor: OpenCodePluginContributor, + pub logical_id: String, + pub description: String, + pub prompt: String, + pub mode: ExternalSubagentMode, + pub hidden: bool, + pub temperature: Option, + pub permission_constraints: PermissionConstraintLayer, + pub plugin_tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenCodePluginSkillRootProjection { + pub path: PathBuf, + pub precedence: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct OpenCodePluginConfigProjection { + pub agents: Vec, + pub skill_roots: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum OpenCodePluginConfigProjectionError { + #[error("{0}")] + Contribution(String), + #[error("{0}")] + Agent(String), + #[error("{0}")] + Permission(String), + #[error("{0}")] + Skill(String), +} + +fn contribution_error(message: impl Into) -> OpenCodePluginConfigProjectionError { + OpenCodePluginConfigProjectionError::Contribution(message.into()) +} + +fn agent_error(message: impl Into) -> OpenCodePluginConfigProjectionError { + OpenCodePluginConfigProjectionError::Agent(message.into()) +} + +fn permission_error(message: impl Into) -> OpenCodePluginConfigProjectionError { + OpenCodePluginConfigProjectionError::Permission(message.into()) +} + +fn skill_error(message: impl Into) -> OpenCodePluginConfigProjectionError { + OpenCodePluginConfigProjectionError::Skill(message.into()) +} + +#[derive(Debug)] +struct ConfigContributor { + plugin: OpenCodePluginContributor, + outcome: ContributorOutcome, +} + +#[derive(Debug, Clone)] +struct ConfigContribution { + plugin: OpenCodePluginContributor, + 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, + } + } +} + +struct ConfigAttribution { + agent_owners: BTreeMap, + permission_owners: BTreeMap<(String, String), OpenCodePluginContributor>, + skill_owners: BTreeMap, +} + +pub fn project_plugin_config( + workspace_root: &Path, + initial_config: &Map, + registration_batch: &HookFunctionRegistrationBatch, +) -> Result { + let contributors = registration_batch + .config_contributors + .iter() + .map(|entry| ConfigContributor { + plugin: OpenCodePluginContributor::from(&entry.plugin), + outcome: entry.outcome.into(), + }) + .collect::>(); + if contributors.is_empty() { + return Ok(OpenCodePluginConfigProjection::default()); + } + + let config = ®istration_batch.config; + let contributions = registration_batch + .config_contributions + .iter() + .map(|entry| ConfigContribution { + plugin: OpenCodePluginContributor::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 agents = Vec::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(|| agent_error(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 plugin_tools = eligible_tools + .into_iter() + .filter(|tool| !denied_plugin_tools.contains(tool)) + .filter_map(|id| { + tool_owners + .get(&id) + .cloned() + .map(|contributor| OpenCodePluginToolRef { contributor, id }) + }) + .collect(); + agents.push(OpenCodePluginAgentProjection { + contributor: owner.clone(), + logical_id, + description, + prompt, + mode, + hidden, + temperature, + permission_constraints, + plugin_tools, + }); + } + + let mut skill_roots = + attributed_skill_roots(config, &attribution.skill_owners, workspace_root)? + .into_values() + .flatten() + .collect::>(); + skill_roots.sort_by_key(|root| root.precedence); + + Ok(OpenCodePluginConfigProjection { + agents, + skill_roots, + }) +} + +fn config_contribution_sequence( + contributions: &[ConfigContribution], + contributors: &[ConfigContributor], + final_config: &Map, +) -> Result, OpenCodePluginConfigProjectionError> { + 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(contribution_error( + "unsupported_multiple_config_contributors: plugin host did not provide configContributions", + )); + } + if contributions.len() != contributors.len() + || contributions + .iter() + .zip(contributors) + .any(|(step, contributor)| { + step.plugin != contributor.plugin || step.outcome != contributor.outcome + }) + { + return Err(contribution_error( + "Plugin config contribution sequence does not match configContributors", + )); + } + if contributions.last().map(|step| &step.config) != Some(final_config) { + return Err(contribution_error( + "Plugin config contribution sequence does not end at the final config", + )); + } + Ok(contributions.to_vec()) +} + +fn attribute_config( + initial_config: &Map, + contributions: &[ConfigContribution], + final_config: &Map, + workspace_root: &Path, +) -> Result { + 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() { + 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(contribution_error( + "Plugin config attribution did not reach the final config", + )); + } + Ok(ConfigAttribution { + agent_owners, + permission_owners, + skill_owners, + }) +} + +fn agent_permission_object( + agent: Option<&Value>, + agent_id: &str, +) -> Result, OpenCodePluginConfigProjectionError> { + let Some(agent) = agent else { + return Ok(Map::new()); + }; + let agent = agent + .as_object() + .ok_or_else(|| agent_error(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(permission_error(format!( + "Plugin agent '{agent_id}' permission must be an object" + ))), + } +} + +fn validate_plugin_identity( + plugin: &OpenCodePluginContributor, +) -> Result<(), OpenCodePluginConfigProjectionError> { + if plugin.spec.trim().is_empty() || plugin.entry.trim().is_empty() { + return Err(contribution_error( + "Plugin config contributor identity is incomplete", + )); + } + Ok(()) +} + +fn validate_tool_id(id: &str) -> Result<(), OpenCodePluginConfigProjectionError> { + if id.is_empty() || id.len() > 256 || id.chars().any(char::is_control) { + return Err(contribution_error("Plugin tool id is invalid")); + } + Ok(()) +} + +fn validate_agent_id(id: &str) -> Result<(), OpenCodePluginConfigProjectionError> { + if id.trim() != id + || id.is_empty() + || id.len() > MAX_AGENT_ID_BYTES + || id.chars().any(char::is_control) + { + return Err(agent_error(format!("Invalid plugin agent id '{id}'"))); + } + Ok(()) +} + +fn parse_mode( + value: Option<&Value>, + id: &str, +) -> Result { + match value.and_then(Value::as_str).unwrap_or("all") { + "primary" => Ok(ExternalSubagentMode::Primary), + "subagent" => Ok(ExternalSubagentMode::Subagent), + "all" => Ok(ExternalSubagentMode::All), + other => Err(agent_error(format!( + "Plugin agent '{id}' has unsupported mode '{other}'" + ))), + } +} + +fn parse_hidden( + value: Option<&Value>, + id: &str, +) -> Result { + match value { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(hidden)) => Ok(*hidden), + Some(_) => Err(agent_error(format!( + "Plugin agent '{id}' hidden must be a boolean" + ))), + } +} + +fn parse_temperature( + value: Option<&Value>, + id: &str, +) -> Result, OpenCodePluginConfigProjectionError> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let temperature = value + .as_f64() + .ok_or_else(|| agent_error(format!("Plugin agent '{id}' temperature must be a number")))?; + if !temperature.is_finite() + || !(MIN_AGENT_TEMPERATURE..=MAX_AGENT_TEMPERATURE).contains(&temperature) + { + return Err(agent_error(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: &OpenCodePluginContributor, +) -> Result { + 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(agent_error( + "Plugin agent description exceeds the size limit", + )); + } + Ok(description) +} + +fn parse_prompt( + value: Option<&Value>, + id: &str, +) -> Result { + let prompt = match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(value)) => value.clone(), + Some(_) => { + return Err(agent_error(format!( + "Plugin agent '{id}' prompt must be a string" + ))) + } + }; + if prompt.len() > MAX_PROMPT_BYTES { + return Err(agent_error(format!( + "Plugin agent '{id}' prompt exceeds the size limit" + ))); + } + Ok(prompt) +} + +fn plugin_tool_ids_by_owner( + tools: &[HookFunctionToolRegistration], +) -> Result< + BTreeMap>, + OpenCodePluginConfigProjectionError, +> { + let mut result = BTreeMap::>::new(); + for tool in tools { + let tool_ref = OpenCodePluginToolRef::try_from(tool)?; + result + .entry(tool_ref.contributor) + .or_default() + .insert(tool_ref.id); + } + Ok(result) +} + +fn parse_permissions( + value: Option<&Value>, + plugin_tools: &BTreeSet, + agent_id: &str, +) -> Result<(PermissionConstraintLayer, BTreeSet), OpenCodePluginConfigProjectionError> { + let Some(value) = value else { + return Ok((PermissionConstraintLayer::default(), BTreeSet::new())); + }; + let permissions = value.as_object().ok_or_else(|| { + permission_error(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(permission_error(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(permission_error(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, +) -> Result, OpenCodePluginConfigProjectionError> { + match config.get(field) { + None => Ok(Map::new()), + Some(Value::Object(value)) => Ok(value.clone()), + Some(_) => Err(agent_error(format!( + "Plugin config '{field}' must be an object" + ))), + } +} + +fn skill_paths( + config: &Map, +) -> Result, OpenCodePluginConfigProjectionError> { + let Some(skills) = config.get("skills") else { + return Ok(Vec::new()); + }; + let skills = skills + .as_object() + .ok_or_else(|| skill_error("Plugin config 'skills' must be an object"))?; + let Some(paths) = skills.get("paths") else { + return Ok(Vec::new()); + }; + let paths = paths + .as_array() + .ok_or_else(|| skill_error("Plugin config 'skills.paths' must be an array"))?; + paths + .iter() + .map(|path| { + path.as_str() + .map(PathBuf::from) + .ok_or_else(|| skill_error("Plugin config 'skills.paths' entries must be strings")) + }) + .collect() +} + +fn resolve_plugin_skill_path( + path: &Path, + workspace_root: &Path, +) -> Result { + let value = path.to_string_lossy(); + let value = value.trim(); + if value.is_empty() || value.contains('\0') { + return Err(skill_error("Plugin skill root path is invalid")); + } + if let Some(relative) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + return dirs::home_dir() + .map(|home| home.join(relative)) + .ok_or_else(|| { + skill_error("Plugin skill root uses '~/' but the home directory is unavailable") + }); + } + 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, +) -> Result { + 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, +) -> Result< + BTreeMap>, + OpenCodePluginConfigProjectionError, +> { + 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(skill_error("Plugin skill root count exceeds the limit")); + } + 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; + }; + roots + .entry(owner.clone()) + .or_default() + .push(OpenCodePluginSkillRootProjection { + path: canonical, + precedence: seen.len() - 1, + }); + } + Ok(roots) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_runtime_ports::{ + HookFunctionConfigContribution, HookFunctionConfigContributor, HookFunctionGeneration, + HookFunctionRegistrationBatch, + }; + use serde_json::json; + + fn plugin(id: &str) -> HookFunctionPluginIdentity { + HookFunctionPluginIdentity { + id: Some(id.to_string()), + spec: format!("D:/plugins/{id}"), + entry: format!("D:/plugins/{id}/index.js"), + index: 0, + } + } + + fn batch( + config: Map, + contributors: Vec, + contributions: Vec, + tools: Vec, + ) -> HookFunctionRegistrationBatch { + HookFunctionRegistrationBatch { + generation: HookFunctionGeneration { + instance_id: "instance".to_string(), + generation_key: "generation".to_string(), + revision: "revision".to_string(), + }, + config, + config_contributors: contributors, + config_contributions: contributions, + diagnostics: Vec::new(), + hooks: Vec::new(), + tools, + } + } + + fn contributor(plugin: HookFunctionPluginIdentity) -> HookFunctionConfigContributor { + HookFunctionConfigContributor { + plugin, + outcome: HookFunctionContributorOutcome::Applied, + } + } + + fn contribution( + plugin: HookFunctionPluginIdentity, + config: Map, + ) -> HookFunctionConfigContribution { + HookFunctionConfigContribution { + plugin, + outcome: HookFunctionContributorOutcome::Applied, + config, + } + } + + fn tool(plugin: HookFunctionPluginIdentity, id: &str) -> HookFunctionToolRegistration { + HookFunctionToolRegistration { + registration_id: format!("registration-{id}"), + id: id.to_string(), + plugin: Some(plugin), + description: String::new(), + parameters: json!({"type": "object"}), + } + } + + #[test] + fn supports_legacy_single_contributor_without_contribution_snapshots() { + let owner = plugin("first"); + let config = json!({"agent": {"build": {"prompt": "Build"}}}) + .as_object() + .unwrap() + .clone(); + let projection = project_plugin_config( + Path::new("C:/workspace"), + &Map::new(), + &batch(config, vec![contributor(owner)], Vec::new(), Vec::new()), + ) + .expect("legacy single contributor"); + + assert_eq!(projection.agents.len(), 1); + assert_eq!(projection.agents[0].logical_id, "build"); + assert_eq!(projection.agents[0].contributor.label(), "first"); + assert_eq!( + projection.agents[0].contributor.stable_key(), + "D:/plugins/first\nD:/plugins/first/index.js\n0" + ); + } + + #[test] + fn rejects_legacy_multiple_contributors_without_contribution_snapshots() { + let first = plugin("first"); + let second = plugin("second"); + let config = json!({"agent": {"build": {"prompt": "Build"}}}) + .as_object() + .unwrap() + .clone(); + let error = project_plugin_config( + Path::new("C:/workspace"), + &Map::new(), + &batch( + config, + vec![contributor(first), contributor(second)], + Vec::new(), + Vec::new(), + ), + ) + .expect_err("multiple contributors require snapshots"); + + assert!(matches!( + &error, + OpenCodePluginConfigProjectionError::Contribution(_) + )); + assert!(error + .to_string() + .contains("unsupported_multiple_config_contributors")); + } + + #[test] + fn rejects_inconsistent_contribution_sequences() { + let first = plugin("first"); + let second = plugin("second"); + let after_first = json!({"agent": {"build": {"prompt": "First"}}}) + .as_object() + .unwrap() + .clone(); + let final_config = json!({"agent": {"build": {"prompt": "Second"}}}) + .as_object() + .unwrap() + .clone(); + let registration = batch( + final_config.clone(), + vec![contributor(first.clone()), contributor(second.clone())], + vec![ + contribution(second, after_first), + contribution(first, final_config), + ], + Vec::new(), + ); + + let error = project_plugin_config(Path::new("C:/workspace"), &Map::new(), ®istration) + .expect_err("contributor order must match"); + assert!(error + .to_string() + .contains("does not match configContributors")); + } + + #[test] + fn isolates_agent_tools_across_multiple_contributors() { + let first = plugin("first"); + let second = plugin("second"); + let after_first = json!({ + "agent": { + "build": { + "prompt": "Build", + "permission": {"first_tool": "allow"} + } + } + }) + .as_object() + .unwrap() + .clone(); + let final_config = json!({ + "agent": { + "build": { + "prompt": "Build", + "description": "Refined by second", + "permission": { + "first_tool": "allow", + "second_tool": "allow" + } + }, + "plan": { + "prompt": "Plan", + "mode": "subagent", + "permission": {"second_tool": "allow"} + } + } + }) + .as_object() + .unwrap() + .clone(); + let registration = batch( + final_config.clone(), + vec![contributor(first.clone()), contributor(second.clone())], + vec![ + contribution(first.clone(), after_first), + contribution(second.clone(), final_config), + ], + vec![tool(first, "first_tool"), tool(second, "second_tool")], + ); + + let projection = + project_plugin_config(Path::new("C:/workspace"), &Map::new(), ®istration) + .expect("multiple contributor projection"); + let build = projection + .agents + .iter() + .find(|agent| agent.logical_id == "build") + .unwrap(); + let plan = projection + .agents + .iter() + .find(|agent| agent.logical_id == "plan") + .unwrap(); + + assert_eq!(build.contributor.label(), "first"); + assert_eq!( + build + .plugin_tools + .iter() + .map(|tool| tool.id.as_str()) + .collect::>(), + vec!["first_tool", "second_tool"] + ); + assert_eq!(plan.contributor.label(), "second"); + assert_eq!( + plan.plugin_tools + .iter() + .map(|tool| tool.id.as_str()) + .collect::>(), + vec!["second_tool"] + ); + } + + #[test] + fn reattributes_deleted_and_recreated_agents_and_permissions() { + let first = OpenCodePluginContributor::from(&plugin("first")); + let second = OpenCodePluginContributor::from(&plugin("second")); + let initial = json!({"agent": {"build": {"prompt": "native"}}}) + .as_object() + .unwrap() + .clone(); + let after_first = json!({"agent": {"build": { + "prompt": "first", + "permission": {"first_tool": "allow"} + }}}) + .as_object() + .unwrap() + .clone(); + let after_delete = json!({"agent": {}}).as_object().unwrap().clone(); + let final_config = json!({"agent": {"build": { + "prompt": "second", + "permission": {"second_tool": "ask"} + }}}) + .as_object() + .unwrap() + .clone(); + let contributions = vec![ + ConfigContribution { + plugin: first, + outcome: ContributorOutcome::Applied, + config: after_first, + }, + ConfigContribution { + plugin: second.clone(), + outcome: ContributorOutcome::Applied, + config: after_delete, + }, + ConfigContribution { + plugin: second.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(&second)); + assert_eq!( + attribution + .permission_owners + .get(&("build".to_string(), "second_tool".to_string())), + Some(&second) + ); + assert!(!attribution + .permission_owners + .contains_key(&("build".to_string(), "first_tool".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!(matches!( + &error, + OpenCodePluginConfigProjectionError::Permission(_) + )); + 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").is_err()); + + assert_eq!(parse_temperature(None, "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()); + } + } + + #[test] + fn rejects_malformed_agent_and_skill_shapes() { + let owner = plugin("first"); + let config = json!({"agent": []}).as_object().unwrap().clone(); + let error = project_plugin_config( + Path::new("C:/workspace"), + &Map::new(), + &batch( + config, + vec![contributor(owner.clone())], + Vec::new(), + Vec::new(), + ), + ) + .expect_err("agent must be an object"); + assert!(matches!( + &error, + OpenCodePluginConfigProjectionError::Agent(_) + )); + assert!(error + .to_string() + .contains("config 'agent' must be an object")); + + for malformed in [json!({"paths": "not-an-array"}), json!({"paths": [42]})] { + let config = json!({"skills": malformed}).as_object().unwrap().clone(); + let error = project_plugin_config( + Path::new("C:/workspace"), + &Map::new(), + &batch( + config, + vec![contributor(owner.clone())], + Vec::new(), + Vec::new(), + ), + ) + .expect_err("malformed skill paths must fail"); + assert!(matches!( + &error, + OpenCodePluginConfigProjectionError::Skill(_) + )); + assert!(error.to_string().contains("skills.paths")); + } + } + + #[test] + fn attributes_skill_additions_without_republishing_initial_roots() { + let workspace = tempfile::tempdir().expect("workspace"); + let initial_root = tempfile::tempdir().expect("initial root"); + let plugin_root = tempfile::tempdir().expect("plugin root"); + let owner = plugin("first"); + let initial = json!({"skills": {"paths": [initial_root.path()]}}) + .as_object() + .unwrap() + .clone(); + let config = json!({"skills": {"paths": [initial_root.path(), plugin_root.path()]}}) + .as_object() + .unwrap() + .clone(); + let registration = batch( + config.clone(), + vec![contributor(owner.clone())], + vec![contribution(owner, config)], + Vec::new(), + ); + + let projection = project_plugin_config(workspace.path(), &initial, ®istration) + .expect("skill projection"); + assert_eq!(projection.skill_roots.len(), 1); + assert_eq!( + projection.skill_roots[0].path, + dunce::canonicalize(plugin_root.path()).unwrap() + ); + assert_eq!(projection.skill_roots[0].precedence, 0); + } + + #[test] + fn canonical_skill_identity_does_not_republish_an_initial_root() { + let directory = tempfile::tempdir().expect("skill root"); + 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 owner = plugin("first"); + let registration = batch( + final_config.clone(), + vec![contributor(owner.clone())], + vec![contribution(owner, final_config)], + Vec::new(), + ); + + let projection = project_plugin_config(directory.path(), &initial, ®istration) + .expect("canonical skill attribution"); + assert!(projection.skill_roots.is_empty()); + } + + #[test] + fn skill_reordering_and_removal_preserve_remaining_owner() { + let workspace = tempfile::tempdir().expect("workspace"); + let base = tempfile::tempdir().expect("base skill root"); + let first_root = tempfile::tempdir().expect("first plugin skill root"); + let second_root = tempfile::tempdir().expect("second plugin skill root"); + let first = OpenCodePluginContributor::from(&plugin("first")); + let second = OpenCodePluginContributor::from(&plugin("second")); + let initial = json!({"skills": {"paths": [base.path()]}}) + .as_object() + .unwrap() + .clone(); + let after_first = json!({"skills": {"paths": [base.path(), first_root.path()]}}) + .as_object() + .unwrap() + .clone(); + let final_config = json!({"skills": {"paths": [second_root.path(), base.path()]}}) + .as_object() + .unwrap() + .clone(); + let contributions = vec![ + ConfigContribution { + plugin: first, + outcome: ContributorOutcome::Applied, + config: after_first, + }, + ConfigContribution { + plugin: second.clone(), + outcome: ContributorOutcome::Applied, + config: final_config.clone(), + }, + ]; + + let attribution = + attribute_config(&initial, &contributions, &final_config, workspace.path()) + .expect("skill removal attribution"); + assert!(!attribution.skill_owners.contains_key( + &normalized_skill_path_identity(first_root.path(), workspace.path()).unwrap() + )); + assert_eq!( + attribution.skill_owners.get( + &normalized_skill_path_identity(second_root.path(), workspace.path()).unwrap() + ), + Some(&second) + ); + } + + #[test] + fn resolves_relative_skill_roots_and_skips_unavailable_roots() { + let workspace = tempfile::tempdir().expect("workspace"); + let skill_root = workspace.path().join("skills"); + std::fs::create_dir(&skill_root).expect("skill root"); + let owner = plugin("first"); + let config = json!({"skills": {"paths": ["./skills", "./missing"]}}) + .as_object() + .unwrap() + .clone(); + let registration = batch( + config.clone(), + vec![contributor(owner.clone())], + vec![contribution(owner, config)], + Vec::new(), + ); + + let projection = project_plugin_config(workspace.path(), &Map::new(), ®istration) + .expect("unavailable roots are isolated"); + assert_eq!(projection.skill_roots.len(), 1); + assert_eq!( + projection.skill_roots[0].path, + dunce::canonicalize(skill_root).unwrap() + ); + } + + #[test] + fn resolves_home_relative_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") + ); + } +} 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 9935163661..2a8c2a2721 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 @@ -1250,7 +1250,7 @@ impl SkillRegistry { #[cfg(feature = "opencode-plugin-host")] { let plugin_roots = - crate::plugin_config_projection::skill_roots_for_agent(workspace_root, agent_type) + crate::plugin_config_publication::skill_roots_for_agent(workspace_root, agent_type) .into_iter() .map(|root| { LocalConfiguredSkillRootContribution { diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index d7a4b7112e..10939c70e0 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -530,7 +530,7 @@ impl ExternalToolMux { }) 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); + .is_some_and(crate::plugin_config_publication::is_plugin_agent_runtime_key); let original = self.original(); if uses_plugin_agent || native_agent_visible || original.is_none() { Some(tool) diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 1fc2c50c42..58b3690eb1 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -37,7 +37,7 @@ pub mod native_hooks; #[cfg(all(test, feature = "agent-runtime"))] mod native_hooks_tests; #[cfg(feature = "opencode-plugin-host")] -mod plugin_config_projection; +mod plugin_config_publication; #[cfg(feature = "opencode-plugin-host")] mod plugin_hook_bridge; #[cfg(feature = "opencode-plugin-host")] diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index 513ec20d96..aa7818aae2 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -99,7 +99,7 @@ pub(crate) async fn dispatch_plugin_tool_before( runtime_agent_key, ) .await; - if crate::plugin_config_projection::is_plugin_agent_runtime_key(runtime_agent_key) + if crate::plugin_config_publication::is_plugin_agent_runtime_key(runtime_agent_key) && generation.is_none() { return Ok(None); @@ -152,7 +152,7 @@ pub(crate) async fn dispatch_plugin_tool_after( runtime_agent_key, ) .await; - if crate::plugin_config_projection::is_plugin_agent_runtime_key(runtime_agent_key) + if crate::plugin_config_publication::is_plugin_agent_runtime_key(runtime_agent_key) && generation.is_none() { return Ok(None); diff --git a/src/crates/assembly/core/src/plugin_config_projection.rs b/src/crates/assembly/core/src/plugin_config_projection.rs deleted file mode 100644 index 669e765e4a..0000000000 --- a/src/crates/assembly/core/src/plugin_config_projection.rs +++ /dev/null @@ -1,1590 +0,0 @@ -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_config_publication.rs b/src/crates/assembly/core/src/plugin_config_publication.rs new file mode 100644 index 0000000000..4d49e9c634 --- /dev/null +++ b/src/crates/assembly/core/src/plugin_config_publication.rs @@ -0,0 +1,472 @@ +use crate::agentic::agents::{ + external_subagent_runtime_key, get_agent_registry, shared_coding_mode_tools, ExploreAgent, + ExternalProvidedAgent, ExternalSubagentModelBinding, ExternalSubagentRegistration, + ExternalSubagentRoute, +}; +use bitfun_opencode_adapter::{OpenCodePluginConfigProjection, OpenCodePluginToolRef}; +use bitfun_product_domains::external_sources::EcosystemId; +use bitfun_product_domains::external_subagents::ExternalSubagentMode; +use bitfun_runtime_ports::{HookFunctionRegistrationBatch, HookFunctionToolRegistration}; +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 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)] +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 PluginConfigPublicationPlan { + workspace_root: PathBuf, + generation_key: String, + registrations: Vec, + routes: BTreeMap, + runtime_agent_keys: BTreeSet, + workspace_skill_roots: Vec, + tool_runtime_agent_keys: BTreeMap>, +} + +impl PluginConfigPublicationPlan { + 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 tool_ref = OpenCodePluginToolRef::try_from(tool) + .map_err(|error| crate::BitFunError::Validation(error.to_string()))?; + Ok(self + .tool_runtime_agent_keys + .get(&tool_ref) + .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 projection = bitfun_opencode_adapter::project_plugin_config( + workspace_root, + initial_config, + registration_batch, + ) + .map_err(|error| crate::BitFunError::Validation(error.to_string()))?; + prepare_projection(workspace_root, generation_key, projection) +} + +fn prepare_projection( + workspace_root: &Path, + generation_key: &str, + projection: OpenCodePluginConfigProjection, +) -> crate::BitFunResult { + let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + if projection.agents.is_empty() && projection.skill_roots.is_empty() { + return Ok(PluginConfigPublicationPlan::empty( + &workspace_root, + generation_key, + )); + } + + let mut registrations = Vec::new(); + let mut routes = BTreeMap::new(); + let mut runtime_agent_keys = BTreeSet::new(); + let mut tool_runtime_agent_keys = BTreeMap::>::new(); + for projected in projection.agents { + let mut tools = + native_tool_baseline(&projected.logical_id, projected.mode, &workspace_root); + let permitted_plugin_tools = projected + .plugin_tools + .iter() + .map(|tool| tool.id.clone()) + .collect::>(); + tools.extend(permitted_plugin_tools.iter().cloned()); + 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(projected.contributor.stable_key().as_bytes()); + hasher.update([0]); + hasher.update(projected.logical_id.as_bytes()); + hasher.update([0]); + hasher.update([u8::from(projected.hidden)]); + hasher.update([0]); + if let Some(temperature) = projected.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(), + projected.logical_id.clone(), + projected.description, + projected.prompt, + tools, + projected.permission_constraints, + projected.temperature, + false, + behavior_version, + )); + registrations.push(ExternalSubagentRegistration { + runtime_key: runtime_key.clone(), + logical_id: projected.logical_id.clone(), + route_key: format!( + "opencode:{}:{}", + hex::encode(Sha256::digest( + projected.contributor.stable_key().as_bytes() + )), + projected.logical_id.to_ascii_lowercase() + ), + ecosystem_id: EcosystemId::new("opencode").map_err(|error| { + crate::BitFunError::Validation(format!("Invalid OpenCode ecosystem id: {error}")) + })?, + provider_label: projected.contributor.label().to_string(), + model_binding: ExternalSubagentModelBinding::InheritParent, + hidden: projected.hidden, + mode: projected.mode, + agent, + }); + routes.insert( + projected.logical_id, + ExternalSubagentRoute::External(runtime_key.clone()), + ); + for tool in projected.plugin_tools { + tool_runtime_agent_keys + .entry(tool) + .or_default() + .insert(runtime_key.clone()); + } + runtime_agent_keys.insert(runtime_key); + } + + let workspace_skill_roots = projection + .skill_roots + .into_iter() + .map(|root| PluginSkillRootContribution { + path: root.path, + precedence: root.precedence, + }) + .collect(); + Ok(PluginConfigPublicationPlan { + workspace_root, + generation_key: generation_key.to_string(), + registrations, + routes, + runtime_agent_keys, + workspace_skill_roots, + tool_runtime_agent_keys, + }) +} + +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() + } +} + +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 bitfun_runtime_ports::{ + HookFunctionConfigContribution, HookFunctionConfigContributor, + HookFunctionContributorOutcome, HookFunctionGeneration, HookFunctionPluginIdentity, + HookFunctionRegistrationBatch, HookFunctionToolRegistration, + }; + use serde_json::{json, Map}; + + fn plugin() -> HookFunctionPluginIdentity { + HookFunctionPluginIdentity { + id: Some("deveco-harness".to_string()), + spec: "D:/code/deveco_harness".to_string(), + entry: "D:/code/deveco_harness/dist/index.js".to_string(), + index: 0, + } + } + + fn registration_batch(config: Map) -> HookFunctionRegistrationBatch { + let plugin = plugin(); + HookFunctionRegistrationBatch { + generation: HookFunctionGeneration { + instance_id: "projection-test".to_string(), + generation_key: "projection-generation".to_string(), + revision: "projection-revision".to_string(), + }, + config: config.clone(), + config_contributors: vec![HookFunctionConfigContributor { + plugin: plugin.clone(), + outcome: HookFunctionContributorOutcome::Applied, + }], + config_contributions: vec![HookFunctionConfigContribution { + plugin: plugin.clone(), + outcome: HookFunctionContributorOutcome::Applied, + config, + }], + diagnostics: Vec::new(), + hooks: Vec::new(), + tools: vec![HookFunctionToolRegistration { + registration_id: "registration-build-project".to_string(), + id: "build_project".to_string(), + plugin: Some(plugin), + description: String::new(), + parameters: json!({"type": "object"}), + }], + } + } + + #[test] + fn generation_scoped_release_never_withdraws_a_replacement() { + let workspace = tempfile::tempdir().expect("workspace"); + PluginConfigPublicationPlan::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 config = json!({"skills": {"paths": [skill_root.path()]}}) + .as_object() + .expect("config object") + .clone(); + let batch = registration_batch(config); + let projection = + bitfun_opencode_adapter::project_plugin_config(workspace.path(), &Map::new(), &batch) + .expect("OpenCode projection"); + let plan = prepare_projection(workspace.path(), "skill-only-generation", projection) + .expect("skill-only plugin publication"); + 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 materializes_projected_agent_fields_and_plugin_tool_permissions() { + let config = json!({ + "agent": { + "build": { + "mode": "primary", + "temperature": 0.7, + "description": "Build projects", + "prompt": "Build prompt", + "permission": {"build_project": "allow"} + } + } + }) + .as_object() + .expect("config object") + .clone(); + let batch = registration_batch(config); + let projection = bitfun_opencode_adapter::project_plugin_config( + Path::new("C:/workspace"), + &Map::new(), + &batch, + ) + .expect("OpenCode projection"); + let plan = prepare_projection(Path::new("C:/workspace"), "generation-1", projection) + .expect("publication"); + + assert_eq!(plan.registrations.len(), 1); + let build = &plan.registrations[0]; + assert_eq!( + build.runtime_key, + "external_subagent_runtime:opencode-plugin:0b17c0646a8c5a8f84a65251bdd750e0b7157ec13115d567608913d87a3763ea" + ); + assert_eq!( + build.route_key, + "opencode:ed485fb494e18771e0611903da426ed83c36f4f19b5c422c7f38712b8aa16d76:build" + ); + 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_eq!(plan.runtime_agent_keys.len(), 1); + assert!(plan + .runtime_agent_keys + .iter() + .all(|key| is_plugin_agent_runtime_key(key))); + assert_eq!( + plan.allowed_runtime_agent_keys_for_tool(&batch.tools[0]) + .expect("tool access"), + plan.runtime_agent_keys + ); + } + + #[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() + ); + } +} diff --git a/src/crates/assembly/core/src/plugin_host.rs b/src/crates/assembly/core/src/plugin_host.rs index d03b27b8b1..3fd9752358 100644 --- a/src/crates/assembly/core/src/plugin_host.rs +++ b/src/crates/assembly/core/src/plugin_host.rs @@ -531,10 +531,10 @@ pub async fn ensure_configured_plugin_instance( }) }; if let Some(instance) = reusable_instance { - if crate::plugin_config_projection::active_generation_key(&canonical_directory).as_deref() + if crate::plugin_config_publication::active_generation_key(&canonical_directory).as_deref() != Some(instance.generation_key.as_str()) { - let projection = crate::plugin_config_projection::prepare( + let publication = crate::plugin_config_publication::prepare( &canonical_directory, &instance.generation_key, &initial_config, @@ -550,7 +550,7 @@ pub async fn ensure_configured_plugin_instance( &comparable_directory, instance.hook_commit_token.as_ref(), ); - projection.commit(); + publication.commit(); } log::debug!( "Configured plugin host instance reused: generation={}, instance_id={}", @@ -704,13 +704,13 @@ pub async fn ensure_configured_plugin_instance( return Err(error); } }; - let config_projection = match crate::plugin_config_projection::prepare( + let config_publication = match crate::plugin_config_publication::prepare( &canonical_directory, &generation_key, &initial_config, ®istration_batch, ) { - Ok(projection) => projection, + Ok(publication) => publication, Err(error) => { discard_opening_plugin_instance( &client, @@ -723,7 +723,7 @@ pub async fn ensure_configured_plugin_instance( return Err(error); } }; - let plugin_agent_runtime_keys = config_projection.agent_runtime_keys(); + let plugin_agent_runtime_keys = config_publication.agent_runtime_keys(); log::info!( "Configured plugin host instance prepared: generation={}, instance_id={}, plugin_count={}", client.generation(), @@ -765,7 +765,7 @@ pub async fn ensure_configured_plugin_instance( &revision, &config_fingerprint, ®istration_batch, - &config_projection, + &config_publication, ) .await { @@ -838,7 +838,7 @@ pub async fn ensure_configured_plugin_instance( &comparable_directory, hook_commit_token.as_ref(), ); - config_projection.commit(); + config_publication.commit(); } if !retire_superseded_plugin_instances( &client, @@ -937,7 +937,7 @@ async fn withdraw_configured_plugin_workspace(directory: &Path) { 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); + crate::plugin_config_publication::release_workspace(canonical); let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); return; @@ -1038,10 +1038,10 @@ async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_gene &instance.generation_key, ) .await; - if crate::plugin_config_projection::active_generation_key(&instance.directory).as_deref() + if crate::plugin_config_publication::active_generation_key(&instance.directory).as_deref() == Some(instance.generation_key.as_str()) { - crate::plugin_config_projection::release_workspace(&instance.directory); + crate::plugin_config_publication::release_workspace(&instance.directory); } if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { bridge.cancel_instance_streams(&instance.instance_id).await; @@ -1061,7 +1061,7 @@ async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_gene }); if !has_replacement { crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace_scope); - crate::plugin_config_projection::release_workspace(&canonical); + crate::plugin_config_publication::release_workspace(&canonical); crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); } } @@ -1205,7 +1205,7 @@ fn schedule_plugin_instance_retirement( } instance.clone() }; - if crate::plugin_config_projection::active_generation_key(&snapshot.directory) + if crate::plugin_config_publication::active_generation_key(&snapshot.directory) .as_deref() == Some(snapshot.generation_key.as_str()) { @@ -1278,7 +1278,7 @@ async fn retire_plugin_instance( }; close_plugin_host_ptys(&instance.instance_id).await; if closed { - crate::plugin_config_projection::release_workspace_generation( + crate::plugin_config_publication::release_workspace_generation( &instance.directory, &instance.generation_key, ); @@ -1702,7 +1702,7 @@ pub async fn shutdown_configured_plugin_host( &instance.generation_key, ) .await; - crate::plugin_config_projection::release_workspace(&instance.directory); + crate::plugin_config_publication::release_workspace(&instance.directory); } let workspaces = instances .values() @@ -1745,7 +1745,7 @@ async fn register_plugin_tools( revision: &str, config_fingerprint: &str, registration_batch: &HookFunctionRegistrationBatch, - projection: &crate::plugin_config_projection::PluginConfigProjectionPlan, + projection: &crate::plugin_config_publication::PluginConfigPublicationPlan, ) -> crate::BitFunResult> { let tools = ®istration_batch.tools; if tools.is_empty() { From 6ed677e7ea0b360020a19011b830862d7d54f741 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 29 Aug 2026 11:16:17 +0800 Subject: [PATCH 2/2] refactor(extensions): decouple plugin capability publication Move provider-neutral Agent, Tool, and Skill contribution contracts into product domains while keeping OpenCode parsing and lifecycle in its adapter and composition path. Scope publication generations by owner so future sibling adapters cannot overwrite each other. --- .../opencode-extension-compatibility.md | 13 + docs/architecture/product-architecture.md | 7 +- scripts/check-core-boundaries.test.mjs | 1 + .../explicit-test-topology.mjs | 1 + .../rules/source/forbidden-rules.mjs | 15 +- .../rules/source/public-api-rules.mjs | 33 +- scripts/core-boundaries/self-test.mjs | 35 +- src/apps/cli/src/dispatch/runner.rs | 2 +- src/crates/adapters/dsh-adapter/AGENTS.md | 8 + .../adapters/opencode-adapter/AGENTS-CN.md | 7 +- .../adapters/opencode-adapter/AGENTS.md | 10 +- .../adapters/opencode-adapter/src/lib.rs | 4 +- .../src/plugin_config_projection.rs | 227 +++++------- .../tools/implementations/skills/registry.rs | 16 +- .../assembly/core/src/external_tools.rs | 2 +- src/crates/assembly/core/src/lib.rs | 2 +- src/crates/assembly/core/src/native_hooks.rs | 4 +- ...on.rs => plugin_capability_publication.rs} | 337 +++++++++--------- src/crates/assembly/core/src/plugin_host.rs | 101 ++++-- .../contracts/product-domains/AGENTS-CN.md | 3 +- .../contracts/product-domains/AGENTS.md | 7 +- .../contracts/product-domains/src/lib.rs | 3 + .../src/plugin_capabilities.rs | 97 +++++ .../tests/external_source_contracts.rs | 2 + .../plugin_capability_contracts.rs | 29 ++ 25 files changed, 617 insertions(+), 349 deletions(-) rename src/crates/assembly/core/src/{plugin_config_publication.rs => plugin_capability_publication.rs} (56%) create mode 100644 src/crates/contracts/product-domains/src/plugin_capabilities.rs create mode 100644 src/crates/contracts/product-domains/tests/external_source_contracts/plugin_capability_contracts.rs diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 237994a815..9d8eac9893 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -356,6 +356,19 @@ Node 进程永久累积;这不是 package-plugin 的 workspace-scoped runtime 执行版本记录不是源码备份。软件包或文件的完整旧版本副本仍在且摘要匹配时可以重建;本地原位源码已变化、 旧 worker 又丢失时不能从当前来源重建后仍称为旧版本。此时只允许准备当前来源或等待用户恢复源码。 +### 4.4 能力投影与多生态边界 + +OpenCode 的 Config Hook、contributor 归属和 Tool registration 仍由 OpenCode adapter 解释。adapter 只把已经验证的 +Agent、Tool 引用和 workspace Skill 根转换成 `product-domains` 的生态无关贡献;Core 的能力发布模块负责选择原生 Tool +基线、生成 runtime/route identity、原子替换 Agent route,并按 `(workspace, publication owner)` 保存 Skill generation。 +因此一个生态更新或撤销时不会覆盖另一个生态的 Skill 贡献。 + +这条公共边界只覆盖当前已经存在的能力提交语义。DeepSeek Harness 当前仍是静态投影,不执行 Cordis 插件;后续增加 +可执行适配时,可以为已经验证过 BitFun owner 语义的能力输出同一贡献类型,但必须保留独立的 Cordis 来源解析、Host +协议、执行句柄和生命周期,也不得进入 OpenCode 的 Config Hook、Hook dispatch 或 Plugin Host 组装路径。当前配置型 +Skill 根的扫描、优先级锚点和合并仍属于 OpenCode consumer;DeepSeek Harness 的 Skill 发布要等真实来源与优先级语义确定后 +再扩展该 owner。本边界不定义统一 Plugin Host、统一插件协议或跨生态配置模型。 + ## 5. 大类风险 | 大类 | 整体风险 | 主要控制点 | diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 95bf3d2bac..bd526b601c 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -653,7 +653,9 @@ flowchart LR 但不能访问内部状态、绕过既有编排或复制业务规则。任何入口都不直接调用 Plugin Host。 - 插件只进入扩展贡献接口,不直接写内核状态、工具结果、权限结果或审计事实。 - 当前 package-plugin 路径由 Core 持有 workspace 逻辑实例和贡献代际,沿类型化 `HookFunctionRuntime` 调用 - OpenCode adapter;adapter 持有共享 Bun Plugin Host 的 wire、连接和物理生命周期,并复用 services 的进程树原语。 + OpenCode adapter;adapter 解释 OpenCode Config/Contributor/Tool 形态后输出生态无关的 Agent、Tool 引用和 Skill 根贡献, + Core 的通用发布模块只把这些贡献提交给既有能力 owner。OpenCode 专属组装路径仍持有 Config 调用和 Tool registration + 转换;adapter 持有共享 Bun Plugin Host 的 wire、连接和物理生命周期,并复用 services 的进程树原语。 legacy managed-package 请求可靠性仍由 `PluginRuntimeClient` 承担,standalone `.js` Tool worker 仍由 `ScriptToolRuntime` 承担。三者不共享生命周期对象,也不能互相外推已交付能力。 - 外部来源的 Command、Tool、Subagent、MCP 仍保留能力专属 DTO 和 owner,但它们的发现调度统一由 @@ -662,7 +664,8 @@ flowchart LR 注入 management owner 后可以调用。通用 Server `/ws` 当前没有绑定可信工作区的 management owner,因此返回类型化 `unsupported`;只有注入 Host 持有的作用域化 owner 并通过 WebSocket round-trip 后,Server 才交付该共享边界。 - 每个生态适配层独立保留该生态的外部格式、来源顺序和调用语义,并映射到 BitFun 归属模块;它本身不成为新的 业务归属模块,也不能依赖或修改兄弟生态 adapter。通用目录、`ExternalSourceControlPlane` 和能力归属模块只依赖开放生态 ID、 - 来源限定身份与能力专属 provider 契约,不按 OpenCode、Codex 或 Claude Code 分支行为。 + 来源限定身份与能力专属 provider 契约,不按 OpenCode、Codex、Claude Code 或 DeepSeek Harness 分支行为。不同生态 + 可以复用已存在的中立贡献 DTO 与发布动作,但不因此共享配置解析、Host 协议、执行句柄或生命周期。 - 产品组装是组装根,只在组装期选择能力、服务实现、插件运行时绑定和降级策略。 - 对外能力接口只提供现有归属模块的窄用例、只读状态、事件和明确错误;它不是第二个 Agent Runtime、通用服务 定位器或插件 Host。外部产品扩展、外部 SDK 控制端和“使用外部 Runtime 组装新产品”是三种不同交付路径, diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index cfb408db48..8a6c859c34 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -849,6 +849,7 @@ test('contract and AI adapter tests keep reviewed feature and failure-domain top 'tests/external_source_contracts/external_hook_catalog_contracts.rs', 'tests/external_source_contracts/external_hook_contribution_contracts.rs', 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/plugin_capability_contracts.rs', 'tests/external_source_contracts/workspace_reference_contracts.rs', ], requiredFeatures: ['external-sources'], diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 06bfac0d97..50e4e2cc54 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -186,6 +186,7 @@ export const productDomainsIntegrationTestTargets = [ 'tests/external_source_contracts/external_hook_catalog_contracts.rs', 'tests/external_source_contracts/external_hook_contribution_contracts.rs', 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/plugin_capability_contracts.rs', 'tests/external_source_contracts/workspace_reference_contracts.rs', ], requiredFeatures: ['external-sources'], diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 5a83fc886b..fa9e026369 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -8,6 +8,19 @@ const agentRuntimeRootUnexpectedLine = new RegExp( ); export const forbiddenContentRules = [ + { + path: 'src/crates/assembly/core/src/plugin_capability_publication.rs', + reason: + 'generic plugin capability publication consumes provider-neutral product contracts and must not absorb ecosystem config, Host wire, or adapter lifecycle', + patterns: [ + { + regex: + /\b(?:OpenCode|DeepSeek|HookFunction|serde_json|bitfun_[a-z0-9_]+_adapter)\b/, + message: + 'plugin capability publication must not depend on ecosystem adapters, raw config, or Host runtime contracts', + }, + ], + }, { path: 'Cargo.toml', reason: @@ -4150,7 +4163,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', - 'src/crates/assembly/core/src/plugin_config_publication.rs', + 'src/crates/assembly/core/src/plugin_host.rs', ], message: 'only a reviewed product composition root may import bitfun-opencode-adapter through a capability-specific provider boundary', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index bc7ab649c4..d8f26ac57d 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -296,12 +296,8 @@ export const opencodeAdapterPublicApiEntries = [ ), ...[ 'project_plugin_config', - 'OpenCodePluginAgentProjection', - 'OpenCodePluginConfigProjection', + 'project_plugin_tool_ref', 'OpenCodePluginConfigProjectionError', - 'OpenCodePluginContributor', - 'OpenCodePluginSkillRootProjection', - 'OpenCodePluginToolRef', ].map(opencodePluginConfigProjectionEntry), opencodeAdapterEntry( 'OpenCodeCommandProvider', @@ -608,6 +604,27 @@ export const externalHookContractPublicApiEntries = [ ), ); +export const pluginCapabilityProjectionPublicApiEntries = [ + 'PluginContributorIdentity', + 'PluginToolRef', + 'PluginAgentProjection', + 'PluginSkillRootContribution', + 'PluginCapabilityProjection', +].map((symbol) => ({ + symbol, + owner: 'product-domains plugin capability projection contract owner', + consumer: 'ecosystem plugin adapters and bitfun-core capability publication', + verification: + 'product-domain projection contract tests, OpenCode projection tests, bitfun-core publication tests, and core-boundary checks', + p0: 'provider-neutral Agent, Tool, and Skill contributions from executable plugin adapters', + contractSlice: contractSlices.bitfunPluginExtension, + wireImpact: false, + rationale: + 'sibling executable plugin adapters need one typed contribution shape without sharing source formats, Host protocols, or lifecycle', + exit: + 'remove only through a reviewed capability-publication migration with equivalent owner isolation and adapter-boundary tests', +})); + export const externalHookCatalogPublicApiEntries = [ 'EXTERNAL_HOOK_CATALOG_SCHEMA_V1', 'ExternalHookSourceKind', @@ -1419,6 +1436,12 @@ export const publicApiAllowlistRules = [ 'external source contracts must stay capability-specific, ecosystem-neutral, and explicitly consumer-backed', allowedSymbolEntries: externalSourceContractPublicApiEntries, }, + { + path: 'src/crates/contracts/product-domains/src/plugin_capabilities.rs', + reason: + 'executable plugin capability projections must stay ecosystem-neutral, behavior-light, and explicitly consumer-backed', + allowedSymbolEntries: pluginCapabilityProjectionPublicApiEntries, + }, { path: 'src/crates/contracts/product-domains/src/external_subagents.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 5180fbe750..aae8607a7a 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1430,6 +1430,22 @@ export function runManifestParserSelfTest({ throw new Error(`plugin runtime boundary rule must forbid: ${forbiddenContract}`); } } + const pluginCapabilityPublicationRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/plugin_capability_publication.rs', + ); + for (const forbiddenDependency of [ + 'OpenCode', + 'DeepSeek', + 'HookFunction', + 'serde_json', + 'bitfun_[a-z0-9_]+_adapter', + ]) { + if (!pluginCapabilityPublicationRuleText.includes(forbiddenDependency)) { + throw new Error( + `plugin capability publication boundary must forbid: ${forbiddenDependency}`, + ); + } + } const pluginPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/runtime-ports/src/plugin.rs', ); @@ -1457,6 +1473,9 @@ export function runManifestParserSelfTest({ const externalSubagentPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_subagents.rs', ); + const pluginCapabilityProjectionPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/contracts/product-domains/src/plugin_capabilities.rs', + ); const externalHookPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_hook_contributions.rs', ); @@ -1604,12 +1623,26 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,load_opencode_config_snapshot,OpenCodeConfigSnapshot,OpenCodeConfigSnapshotError,project_plugin_config,OpenCodePluginAgentProjection,OpenCodePluginConfigProjection,OpenCodePluginConfigProjectionError,OpenCodePluginContributor,OpenCodePluginSkillRootProjection,OpenCodePluginToolRef,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,project_plugin_config,project_plugin_tool_ref,OpenCodePluginConfigProjectionError,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, managed Plugin Host Config projection, and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, workspace Reference, and user Instruction providers', ); } + if (!pluginCapabilityProjectionPublicApiRule) { + throw new Error('plugin capability projection must have a public API budget rule'); + } + const pluginCapabilityProjectionSymbols = ( + pluginCapabilityProjectionPublicApiRule.allowedSymbolEntries || [] + ).map((entry) => entry.symbol); + if ( + pluginCapabilityProjectionSymbols.join(',') !== + 'PluginContributorIdentity,PluginToolRef,PluginAgentProjection,PluginSkillRootContribution,PluginCapabilityProjection' + ) { + throw new Error( + 'plugin capability projection public API must stay limited to the reviewed provider-neutral contribution DTOs', + ); + } const opencodeInstructionSymbols = new Set([ 'load_opencode_user_instructions', 'OpenCodeInstructionSourceOptions', diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 0aaac7308a..7741531722 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -501,7 +501,6 @@ mod tests { let mut leader = command.spawn().expect("spawn process-group leader"); let process_group = i32::try_from(leader.id()).expect("safe pid"); let _guard = ProcessGroupGuard(process_group); - assert!(worker_process_alive(process_group as u32, job_id)); for _ in 0..100 { if ready_path.is_file() { break; @@ -512,6 +511,7 @@ mod tests { ready_path.is_file(), "TERM-resistant child must be ready before cancellation" ); + assert!(worker_process_alive(process_group as u32, job_id)); let reaper = std::thread::spawn(move || leader.wait()); let error = terminate_worker(process_group as u32, job_id) diff --git a/src/crates/adapters/dsh-adapter/AGENTS.md b/src/crates/adapters/dsh-adapter/AGENTS.md index c7d742cce0..074d0b4565 100644 --- a/src/crates/adapters/dsh-adapter/AGENTS.md +++ b/src/crates/adapters/dsh-adapter/AGENTS.md @@ -11,6 +11,14 @@ It does not execute Cordis plugins, install npm packages, or depend on a user-local `dsh` CLI. Execution of dsh bundles belongs to future Plugin Host / external-ACP work, not this adapter boundary. +If executable dsh support is added, the dsh adapter may project capabilities +whose BitFun owner semantics have been verified through the provider-neutral +plugin capability contract. Cordis source parsing, execution handles, Host +protocol, and lifecycle remain dsh-owned and must not reuse the OpenCode Config +Hook or OpenCode Plugin Host composition path. The current configured Skill-root +merge and precedence behavior remains OpenCode-owned; dsh Skill publication +requires its own consumer evidence before that path is shared. + ## Boundary Rules - Depend on stable contracts (`bitfun-runtime-ports`, `bitfun-product-domains`) diff --git a/src/crates/adapters/opencode-adapter/AGENTS-CN.md b/src/crates/adapters/opencode-adapter/AGENTS-CN.md index a08ca6d55c..4c6b872d48 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS-CN.md +++ b/src/crates/adapters/opencode-adapter/AGENTS-CN.md @@ -38,9 +38,10 @@ Agent/权限/Plugin Tool/Skill 投影,以及 Command、standalone Tool 和 Sub - 依赖 `bitfun-runtime-ports` 等稳定接口和 `PluginRuntimeAdapter` 边界 trait,不依赖 `bitfun-core`、app crate、Tauri API、产品界面或具体服务管理器。 - OpenCode 配置 JSON、来源顺序、加载器兼容、参数展开和受管 Plugin Host Config 贡献归属保留在本 crate 内。 - 跨 crate 输出使用类型化来源快照、Agent/权限/Plugin Tool/Skill 投影、adapter 绑定和 `PluginRuntimeClient` DTO, + 跨 crate 输出使用类型化来源快照、生态无关的 Agent/权限/Plugin Tool/Skill 贡献、adapter 绑定和 `PluginRuntimeClient` DTO, 不得把 OpenCode 原始 JSON 或源码语法暴露为产品接口。Config 投影不得生成 Agent runtime key、选择原生 Tool - 基线、修改产品 registry 或扩成通用 Config 平台。 + 基线、修改产品 registry 或扩成通用 Config 平台;OpenCode contributor 解析和 Tool registration 转换仍留在本 crate, + 不得让 OpenCode 专属 DTO 进入通用发布模块。 - 当前源码探测只识别测试覆盖的声明式语法子集,不是通用 JS/TS 解析器;没有可识别入口的包和已识别但不支持的 hook 必须返回诊断,其他语法不属于当前兼容范围。 - 未支持的 OpenCode 能力必须显式返回类型化诊断或不支持状态,不得静默忽略。 @@ -49,7 +50,7 @@ Agent/权限/Plugin Tool/Skill 投影,以及 Command、standalone Tool 和 Sub - 经评审的产品组装根只选择并构造已编译的 OpenCode adapter/provider,再注入 `PluginRuntimeClient`;它不发现 动态来源、不准备依赖,也不 import 插件模块。 - Product Assembly 只允许从经过评审的组装模块(如 `bitfun-core/plugin_runtime`、 - `bitfun-core/external_sources` 或 `bitfun-core/plugin_config_publication`)消费本 crate;增加其他消费方时必须同步 + `bitfun-core/external_sources` 或 OpenCode 专属的 `bitfun-core/plugin_host` 组装路径)消费本 crate;增加其他消费方时必须同步 边界脚本和聚焦组装路径测试。 - 本 crate 不得依赖 Codex、Claude Code 或其他生态 adapter。新生态是由 Product Assembly 注册的同级 adapter, 不是本 adapter 的模式。 diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index 42f2ebe56d..090bc8defe 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -84,8 +84,11 @@ Product-source boundary: relative paths only from an explicit workspace context. - Managed Plugin Host Config projection validates contributor attribution and maps only the currently consumed Agent, permission, Plugin Tool, and workspace - Skill fields. It must not create Agent runtime keys, select native Tool - baselines, mutate product registries, or grow into a generic Config platform. + Skill fields into the provider-neutral plugin capability contract. OpenCode + contributor parsing and Tool registration conversion stay here; adapter-owned + DTOs must not leak into generic publication. This projection must not create + Agent runtime keys, select native Tool baselines, mutate product registries, + or grow into a generic Config platform. - 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 @@ -113,7 +116,8 @@ Product-source boundary: discover dynamic sources, prepare dependencies, or import plugin modules. - Product Assembly may consume this crate only from reviewed composition modules such as `bitfun-core/plugin_runtime`, `bitfun-core/external_sources`, - `bitfun-core/instruction_sources`, or `bitfun-core/plugin_config_publication`; boundary + `bitfun-core/instruction_sources`, or the OpenCode-specific + `bitfun-core/plugin_host` composition path; boundary guards and focused assembly-path tests must change with any additional consumer. - This crate must not depend on Codex, Claude Code, or another ecosystem adapter. New ecosystems are sibling adapters registered by Product Assembly, not modes of diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index f221600fab..fc557f9fb6 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -25,9 +25,7 @@ pub use hook_source::{OpenCodeHookProvider, OpenCodeHookProviderOptions}; pub use instruction_source::{load_opencode_user_instructions, OpenCodeInstructionSourceOptions}; pub use mcp_source::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; pub use plugin_config_projection::{ - project_plugin_config, OpenCodePluginAgentProjection, OpenCodePluginConfigProjection, - OpenCodePluginConfigProjectionError, OpenCodePluginContributor, - OpenCodePluginSkillRootProjection, OpenCodePluginToolRef, + project_plugin_config, project_plugin_tool_ref, OpenCodePluginConfigProjectionError, }; pub use reference_source::{ OpenCodeWorkspaceReferenceProvider, OpenCodeWorkspaceReferenceProviderOptions, diff --git a/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs b/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs index 8693da43d6..438a0e4421 100644 --- a/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs +++ b/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs @@ -1,4 +1,8 @@ use bitfun_product_domains::external_subagents::ExternalSubagentMode; +use bitfun_product_domains::plugin_capabilities::{ + PluginAgentProjection, PluginCapabilityProjection, PluginContributorIdentity, + PluginSkillRootContribution, PluginToolRef, +}; use bitfun_product_domains::tool_permissions::{ PermissionConstraintLayer, PermissionEffect, PermissionRule, }; @@ -17,88 +21,6 @@ const MAX_PLUGIN_SKILL_ROOTS: usize = 64; const MIN_AGENT_TEMPERATURE: f64 = 0.0; const MAX_AGENT_TEMPERATURE: f64 = 2.0; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct OpenCodePluginContributor { - id: Option, - spec: String, - entry: String, - index: usize, - stable_key: String, - label: String, -} - -impl OpenCodePluginContributor { - pub fn stable_key(&self) -> &str { - &self.stable_key - } - - pub fn label(&self) -> &str { - &self.label - } -} - -impl From<&HookFunctionPluginIdentity> for OpenCodePluginContributor { - fn from(value: &HookFunctionPluginIdentity) -> Self { - Self { - id: value.id.clone(), - spec: value.spec.clone(), - entry: value.entry.clone(), - index: value.index, - stable_key: format!("{}\n{}\n{}", value.spec, value.entry, value.index), - label: value.id.clone().unwrap_or_else(|| value.spec.clone()), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct OpenCodePluginToolRef { - pub contributor: OpenCodePluginContributor, - pub id: String, -} - -impl TryFrom<&HookFunctionToolRegistration> for OpenCodePluginToolRef { - type Error = OpenCodePluginConfigProjectionError; - - fn try_from(tool: &HookFunctionToolRegistration) -> Result { - let contributor = tool - .plugin - .as_ref() - .map(OpenCodePluginContributor::from) - .ok_or_else(|| contribution_error("Plugin tool identity is missing"))?; - validate_plugin_identity(&contributor)?; - validate_tool_id(&tool.id)?; - Ok(Self { - contributor, - id: tool.id.clone(), - }) - } -} - -#[derive(Debug, Clone)] -pub struct OpenCodePluginAgentProjection { - pub contributor: OpenCodePluginContributor, - pub logical_id: String, - pub description: String, - pub prompt: String, - pub mode: ExternalSubagentMode, - pub hidden: bool, - pub temperature: Option, - pub permission_constraints: PermissionConstraintLayer, - pub plugin_tools: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpenCodePluginSkillRootProjection { - pub path: PathBuf, - pub precedence: usize, -} - -#[derive(Debug, Clone, Default)] -pub struct OpenCodePluginConfigProjection { - pub agents: Vec, - pub skill_roots: Vec, -} - #[derive(Debug, thiserror::Error)] pub enum OpenCodePluginConfigProjectionError { #[error("{0}")] @@ -129,13 +51,13 @@ fn skill_error(message: impl Into) -> OpenCodePluginConfigProjectionErro #[derive(Debug)] struct ConfigContributor { - plugin: OpenCodePluginContributor, + plugin: PluginContributorIdentity, outcome: ContributorOutcome, } #[derive(Debug, Clone)] struct ConfigContribution { - plugin: OpenCodePluginContributor, + plugin: PluginContributorIdentity, outcome: ContributorOutcome, config: Map, } @@ -156,38 +78,84 @@ impl From for ContributorOutcome { } struct ConfigAttribution { - agent_owners: BTreeMap, - permission_owners: BTreeMap<(String, String), OpenCodePluginContributor>, - skill_owners: BTreeMap, + agent_owners: BTreeMap, + permission_owners: BTreeMap<(String, String), PluginContributorIdentity>, + skill_owners: BTreeMap, +} + +fn identity_component(value: &str) -> String { + format!("{}:{value}", value.len()) +} + +fn project_plugin_identity( + plugin: &HookFunctionPluginIdentity, +) -> Result { + if plugin.spec.trim().is_empty() || plugin.entry.trim().is_empty() { + return Err(contribution_error( + "Plugin config contributor identity is incomplete", + )); + } + let id = plugin.id.as_deref().unwrap_or_default(); + let identity_key = format!( + "{}{}{}{}", + identity_component(if plugin.id.is_some() { "1" } else { "0" }), + identity_component(id), + identity_component(&plugin.spec), + identity_component(&plugin.entry), + ); + Ok(PluginContributorIdentity::new( + format!( + "{identity_key}{}", + identity_component(&plugin.index.to_string()) + ), + format!("{}\n{}\n{}", plugin.spec, plugin.entry, plugin.index), + plugin.id.clone().unwrap_or_else(|| plugin.spec.clone()), + )) +} + +pub fn project_plugin_tool_ref( + tool: &HookFunctionToolRegistration, +) -> Result { + let contributor = tool + .plugin + .as_ref() + .ok_or_else(|| contribution_error("Plugin tool identity is missing")) + .and_then(project_plugin_identity)?; + validate_tool_id(&tool.id)?; + Ok(PluginToolRef::new(contributor, tool.id.clone())) } pub fn project_plugin_config( workspace_root: &Path, initial_config: &Map, registration_batch: &HookFunctionRegistrationBatch, -) -> Result { +) -> Result { let contributors = registration_batch .config_contributors .iter() - .map(|entry| ConfigContributor { - plugin: OpenCodePluginContributor::from(&entry.plugin), - outcome: entry.outcome.into(), + .map(|entry| { + Ok(ConfigContributor { + plugin: project_plugin_identity(&entry.plugin)?, + outcome: entry.outcome.into(), + }) }) - .collect::>(); + .collect::, OpenCodePluginConfigProjectionError>>()?; if contributors.is_empty() { - return Ok(OpenCodePluginConfigProjection::default()); + return Ok(PluginCapabilityProjection::default()); } let config = ®istration_batch.config; let contributions = registration_batch .config_contributions .iter() - .map(|entry| ConfigContribution { - plugin: OpenCodePluginContributor::from(&entry.plugin), - outcome: entry.outcome.into(), - config: entry.config.clone(), + .map(|entry| { + Ok(ConfigContribution { + plugin: project_plugin_identity(&entry.plugin)?, + outcome: entry.outcome.into(), + config: entry.config.clone(), + }) }) - .collect::>(); + .collect::, OpenCodePluginConfigProjectionError>>()?; 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")?; @@ -239,10 +207,10 @@ pub fn project_plugin_config( tool_owners .get(&id) .cloned() - .map(|contributor| OpenCodePluginToolRef { contributor, id }) + .map(|contributor| PluginToolRef::new(contributor, id)) }) .collect(); - agents.push(OpenCodePluginAgentProjection { + agents.push(PluginAgentProjection { contributor: owner.clone(), logical_id, description, @@ -262,7 +230,7 @@ pub fn project_plugin_config( .collect::>(); skill_roots.sort_by_key(|root| root.precedence); - Ok(OpenCodePluginConfigProjection { + Ok(PluginCapabilityProjection { agents, skill_roots, }) @@ -321,7 +289,6 @@ fn attribute_config( .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 @@ -402,17 +369,6 @@ fn agent_permission_object( } } -fn validate_plugin_identity( - plugin: &OpenCodePluginContributor, -) -> Result<(), OpenCodePluginConfigProjectionError> { - if plugin.spec.trim().is_empty() || plugin.entry.trim().is_empty() { - return Err(contribution_error( - "Plugin config contributor identity is incomplete", - )); - } - Ok(()) -} - fn validate_tool_id(id: &str) -> Result<(), OpenCodePluginConfigProjectionError> { if id.is_empty() || id.len() > 256 || id.chars().any(char::is_control) { return Err(contribution_error("Plugin tool id is invalid")); @@ -483,7 +439,7 @@ fn parse_temperature( fn parse_description( value: Option<&Value>, - plugin: &OpenCodePluginContributor, + plugin: &PluginContributorIdentity, ) -> Result { let description = value .and_then(Value::as_str) @@ -523,16 +479,16 @@ fn parse_prompt( fn plugin_tool_ids_by_owner( tools: &[HookFunctionToolRegistration], ) -> Result< - BTreeMap>, + BTreeMap>, OpenCodePluginConfigProjectionError, > { - let mut result = BTreeMap::>::new(); + let mut result = BTreeMap::>::new(); for tool in tools { - let tool_ref = OpenCodePluginToolRef::try_from(tool)?; + let tool_ref = project_plugin_tool_ref(tool)?; result - .entry(tool_ref.contributor) + .entry(tool_ref.contributor().clone()) .or_default() - .insert(tool_ref.id); + .insert(tool_ref.id().to_string()); } Ok(result) } @@ -669,15 +625,14 @@ fn normalized_skill_path_identity( fn attributed_skill_roots( final_config: &Map, - owners: &BTreeMap, + owners: &BTreeMap, workspace_root: &Path, ) -> Result< - BTreeMap>, + BTreeMap>, OpenCodePluginConfigProjectionError, > { let mut seen = BTreeSet::new(); - let mut roots = - BTreeMap::>::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)?; @@ -706,7 +661,7 @@ fn attributed_skill_roots( roots .entry(owner.clone()) .or_default() - .push(OpenCodePluginSkillRootProjection { + .push(PluginSkillRootContribution { path: canonical, precedence: seen.len() - 1, }); @@ -781,6 +736,16 @@ mod tests { } } + #[test] + fn projects_owner_scoped_tool_reference_for_publication() { + let owner = plugin("first"); + let projected = + project_plugin_tool_ref(&tool(owner, "build_project")).expect("plugin tool reference"); + + assert_eq!(projected.contributor().label(), "first"); + assert_eq!(projected.id(), "build_project"); + } + #[test] fn supports_legacy_single_contributor_without_contribution_snapshots() { let owner = plugin("first"); @@ -799,7 +764,7 @@ mod tests { assert_eq!(projection.agents[0].logical_id, "build"); assert_eq!(projection.agents[0].contributor.label(), "first"); assert_eq!( - projection.agents[0].contributor.stable_key(), + projection.agents[0].contributor.behavior_key(), "D:/plugins/first\nD:/plugins/first/index.js\n0" ); } @@ -926,7 +891,7 @@ mod tests { build .plugin_tools .iter() - .map(|tool| tool.id.as_str()) + .map(PluginToolRef::id) .collect::>(), vec!["first_tool", "second_tool"] ); @@ -934,7 +899,7 @@ mod tests { assert_eq!( plan.plugin_tools .iter() - .map(|tool| tool.id.as_str()) + .map(PluginToolRef::id) .collect::>(), vec!["second_tool"] ); @@ -942,8 +907,8 @@ mod tests { #[test] fn reattributes_deleted_and_recreated_agents_and_permissions() { - let first = OpenCodePluginContributor::from(&plugin("first")); - let second = OpenCodePluginContributor::from(&plugin("second")); + let first = project_plugin_identity(&plugin("first")).unwrap(); + let second = project_plugin_identity(&plugin("second")).unwrap(); let initial = json!({"agent": {"build": {"prompt": "native"}}}) .as_object() .unwrap() @@ -1149,8 +1114,8 @@ mod tests { let base = tempfile::tempdir().expect("base skill root"); let first_root = tempfile::tempdir().expect("first plugin skill root"); let second_root = tempfile::tempdir().expect("second plugin skill root"); - let first = OpenCodePluginContributor::from(&plugin("first")); - let second = OpenCodePluginContributor::from(&plugin("second")); + let first = project_plugin_identity(&plugin("first")).unwrap(); + let second = project_plugin_identity(&plugin("second")).unwrap(); let initial = json!({"skills": {"paths": [base.path()]}}) .as_object() .unwrap() 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 2a8c2a2721..199ba47fac 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 @@ -1249,18 +1249,18 @@ impl SkillRegistry { ) -> Vec { #[cfg(feature = "opencode-plugin-host")] { - let plugin_roots = - crate::plugin_config_publication::skill_roots_for_agent(workspace_root, agent_type) - .into_iter() - .map(|root| { - LocalConfiguredSkillRootContribution { + let plugin_roots = crate::plugin_capability_publication::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::>(); + }) + .collect::>(); if !plugin_roots.is_empty() { let existing_paths = candidates .iter() diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index 10939c70e0..6d24773502 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -530,7 +530,7 @@ impl ExternalToolMux { }) 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_publication::is_plugin_agent_runtime_key); + .is_some_and(crate::plugin_host::is_opencode_plugin_agent_runtime_key); let original = self.original(); if uses_plugin_agent || native_agent_visible || original.is_none() { Some(tool) diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 58b3690eb1..1209816889 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -37,7 +37,7 @@ pub mod native_hooks; #[cfg(all(test, feature = "agent-runtime"))] mod native_hooks_tests; #[cfg(feature = "opencode-plugin-host")] -mod plugin_config_publication; +mod plugin_capability_publication; #[cfg(feature = "opencode-plugin-host")] mod plugin_hook_bridge; #[cfg(feature = "opencode-plugin-host")] diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index aa7818aae2..0cfd74b17a 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -99,7 +99,7 @@ pub(crate) async fn dispatch_plugin_tool_before( runtime_agent_key, ) .await; - if crate::plugin_config_publication::is_plugin_agent_runtime_key(runtime_agent_key) + if crate::plugin_host::is_opencode_plugin_agent_runtime_key(runtime_agent_key) && generation.is_none() { return Ok(None); @@ -152,7 +152,7 @@ pub(crate) async fn dispatch_plugin_tool_after( runtime_agent_key, ) .await; - if crate::plugin_config_publication::is_plugin_agent_runtime_key(runtime_agent_key) + if crate::plugin_host::is_opencode_plugin_agent_runtime_key(runtime_agent_key) && generation.is_none() { return Ok(None); diff --git a/src/crates/assembly/core/src/plugin_config_publication.rs b/src/crates/assembly/core/src/plugin_capability_publication.rs similarity index 56% rename from src/crates/assembly/core/src/plugin_config_publication.rs rename to src/crates/assembly/core/src/plugin_capability_publication.rs index 4d49e9c634..11da4e27fd 100644 --- a/src/crates/assembly/core/src/plugin_config_publication.rs +++ b/src/crates/assembly/core/src/plugin_capability_publication.rs @@ -3,20 +3,40 @@ use crate::agentic::agents::{ ExternalProvidedAgent, ExternalSubagentModelBinding, ExternalSubagentRegistration, ExternalSubagentRoute, }; -use bitfun_opencode_adapter::{OpenCodePluginConfigProjection, OpenCodePluginToolRef}; use bitfun_product_domains::external_sources::EcosystemId; use bitfun_product_domains::external_subagents::ExternalSubagentMode; -use bitfun_runtime_ports::{HookFunctionRegistrationBatch, HookFunctionToolRegistration}; -use serde_json::{Map, Value}; +use bitfun_product_domains::plugin_capabilities::{PluginCapabilityProjection, PluginToolRef}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock, RwLock}; -const OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER: &str = "opencode-plugin-config"; +pub(crate) fn is_agent_runtime_key_for_namespace( + runtime_agent_key: &str, + runtime_namespace: &str, +) -> bool { + runtime_agent_key.starts_with(&format!("external_subagent_runtime:{runtime_namespace}:")) +} + +#[derive(Debug, Clone)] +pub(crate) struct PluginPublicationIdentity { + ecosystem_id: String, + runtime_namespace: String, + route_owner: String, +} -pub(crate) fn is_plugin_agent_runtime_key(runtime_agent_key: &str) -> bool { - runtime_agent_key.starts_with("external_subagent_runtime:opencode-plugin:") +impl PluginPublicationIdentity { + pub(crate) fn new( + ecosystem_id: impl Into, + runtime_namespace: impl Into, + route_owner: impl Into, + ) -> Self { + Self { + ecosystem_id: ecosystem_id.into(), + runtime_namespace: runtime_namespace.into(), + route_owner: route_owner.into(), + } + } } #[derive(Debug, Clone)] @@ -31,27 +51,33 @@ struct PublishedSkillGeneration { workspace_roots: Vec, } -fn skill_generations() -> &'static RwLock> { - static GENERATIONS: OnceLock>> = +fn skill_generations() -> &'static RwLock> { + static GENERATIONS: OnceLock>> = OnceLock::new(); GENERATIONS.get_or_init(|| RwLock::new(HashMap::new())) } -pub(crate) struct PluginConfigPublicationPlan { +pub(crate) struct PluginCapabilityPublicationPlan { workspace_root: PathBuf, generation_key: String, + publication: PluginPublicationIdentity, registrations: Vec, routes: BTreeMap, runtime_agent_keys: BTreeSet, workspace_skill_roots: Vec, - tool_runtime_agent_keys: BTreeMap>, + tool_runtime_agent_keys: BTreeMap>, } -impl PluginConfigPublicationPlan { - pub(crate) fn empty(workspace_root: &Path, generation_key: &str) -> Self { +impl PluginCapabilityPublicationPlan { + pub(crate) fn empty( + workspace_root: &Path, + generation_key: &str, + publication: PluginPublicationIdentity, + ) -> Self { Self { workspace_root: workspace_root.to_path_buf(), generation_key: generation_key.to_string(), + publication, registrations: Vec::new(), routes: BTreeMap::new(), runtime_agent_keys: BTreeSet::new(), @@ -66,21 +92,18 @@ impl PluginConfigPublicationPlan { pub(crate) fn allowed_runtime_agent_keys_for_tool( &self, - tool: &HookFunctionToolRegistration, - ) -> crate::BitFunResult> { - let tool_ref = OpenCodePluginToolRef::try_from(tool) - .map_err(|error| crate::BitFunError::Validation(error.to_string()))?; - Ok(self - .tool_runtime_agent_keys - .get(&tool_ref) + tool: &PluginToolRef, + ) -> BTreeSet { + self.tool_runtime_agent_keys + .get(tool) .cloned() - .unwrap_or_default()) + .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.publication.route_owner, self.registrations, self.routes, ); @@ -88,7 +111,7 @@ impl PluginConfigPublicationPlan { .write() .expect("plugin skill generation lock poisoned"); generations.insert( - self.workspace_root, + (self.workspace_root, self.publication.route_owner), PublishedSkillGeneration { generation_key: self.generation_key, workspace_roots: self.workspace_skill_roots, @@ -97,20 +120,18 @@ impl PluginConfigPublicationPlan { } } -pub(crate) fn release_workspace(workspace_root: &Path) { +pub(crate) fn release_workspace(workspace_root: &Path, route_owner: &str) { 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, - ); + get_agent_registry().release_external_subagent_route_overlay(&workspace_root, route_owner); skill_generations() .write() .expect("plugin skill generation lock poisoned") - .remove(&workspace_root); + .remove(&(workspace_root, route_owner.to_string())); } pub(crate) fn release_workspace_generation( workspace_root: &Path, + route_owner: &str, expected_generation_key: &str, ) -> bool { let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); @@ -118,16 +139,13 @@ pub(crate) fn release_workspace_generation( .write() .expect("plugin skill generation lock poisoned"); if generations - .get(&workspace_root) + .get(&(workspace_root.clone(), route_owner.to_string())) .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); + get_agent_registry().release_external_subagent_route_overlay(&workspace_root, route_owner); + generations.remove(&(workspace_root, route_owner.to_string())); true } @@ -139,53 +157,58 @@ pub(crate) fn skill_roots_for_agent( return Vec::new(); }; let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); - skill_generations() + let generations = skill_generations() .read() - .expect("plugin skill generation lock poisoned") - .get(&workspace_root) - .map(|generation| generation.workspace_roots.clone()) - .unwrap_or_default() + .expect("plugin skill generation lock poisoned"); + let mut publications = generations + .iter() + .filter(|((root, _), _)| root == &workspace_root) + .collect::>(); + publications.sort_by(|((_, left), _), ((_, right), _)| left.cmp(right)); + publications + .into_iter() + .flat_map(|(_, generation)| generation.workspace_roots.iter().cloned()) + .enumerate() + .map(|(precedence, mut root)| { + root.precedence = precedence; + root + }) + .collect() } pub(crate) fn prepare( workspace_root: &Path, generation_key: &str, - initial_config: &Map, - registration_batch: &HookFunctionRegistrationBatch, -) -> crate::BitFunResult { - let projection = bitfun_opencode_adapter::project_plugin_config( - workspace_root, - initial_config, - registration_batch, - ) - .map_err(|error| crate::BitFunError::Validation(error.to_string()))?; - prepare_projection(workspace_root, generation_key, projection) -} - -fn prepare_projection( - workspace_root: &Path, - generation_key: &str, - projection: OpenCodePluginConfigProjection, -) -> crate::BitFunResult { + publication: PluginPublicationIdentity, + projection: PluginCapabilityProjection, +) -> crate::BitFunResult { let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); if projection.agents.is_empty() && projection.skill_roots.is_empty() { - return Ok(PluginConfigPublicationPlan::empty( + return Ok(PluginCapabilityPublicationPlan::empty( &workspace_root, generation_key, + publication, )); } + let ecosystem_id = EcosystemId::new(&publication.ecosystem_id).map_err(|error| { + crate::BitFunError::Validation(format!( + "Invalid plugin publication ecosystem id '{}': {error}", + publication.ecosystem_id + )) + })?; + let mut registrations = Vec::new(); let mut routes = BTreeMap::new(); let mut runtime_agent_keys = BTreeSet::new(); - let mut tool_runtime_agent_keys = BTreeMap::>::new(); + let mut tool_runtime_agent_keys = BTreeMap::>::new(); for projected in projection.agents { let mut tools = native_tool_baseline(&projected.logical_id, projected.mode, &workspace_root); let permitted_plugin_tools = projected .plugin_tools .iter() - .map(|tool| tool.id.clone()) + .map(|tool| tool.id().to_string()) .collect::>(); tools.extend(permitted_plugin_tools.iter().cloned()); for plugin_tool in &permitted_plugin_tools { @@ -200,7 +223,7 @@ fn prepare_projection( let mut hasher = Sha256::new(); hasher.update(generation_key.as_bytes()); hasher.update([0]); - hasher.update(projected.contributor.stable_key().as_bytes()); + hasher.update(projected.contributor.behavior_key().as_bytes()); hasher.update([0]); hasher.update(projected.logical_id.as_bytes()); hasher.update([0]); @@ -212,7 +235,8 @@ fn prepare_projection( hasher.update([0xff]); } let digest = hex::encode(hasher.finalize()); - let runtime_key = external_subagent_runtime_key(&format!("opencode-plugin:{digest}")); + let runtime_key = + external_subagent_runtime_key(&format!("{}:{digest}", publication.runtime_namespace)); let behavior_version = format!("sha256:{digest}"); let agent = Arc::new(ExternalProvidedAgent::new( runtime_key.clone(), @@ -229,15 +253,14 @@ fn prepare_projection( runtime_key: runtime_key.clone(), logical_id: projected.logical_id.clone(), route_key: format!( - "opencode:{}:{}", + "{}:{}:{}", + publication.ecosystem_id, hex::encode(Sha256::digest( - projected.contributor.stable_key().as_bytes() + projected.contributor.behavior_key().as_bytes() )), projected.logical_id.to_ascii_lowercase() ), - ecosystem_id: EcosystemId::new("opencode").map_err(|error| { - crate::BitFunError::Validation(format!("Invalid OpenCode ecosystem id: {error}")) - })?, + ecosystem_id: ecosystem_id.clone(), provider_label: projected.contributor.label().to_string(), model_binding: ExternalSubagentModelBinding::InheritParent, hidden: projected.hidden, @@ -257,17 +280,19 @@ fn prepare_projection( runtime_agent_keys.insert(runtime_key); } - let workspace_skill_roots = projection + let mut workspace_skill_roots = projection .skill_roots .into_iter() .map(|root| PluginSkillRootContribution { path: root.path, precedence: root.precedence, }) - .collect(); - Ok(PluginConfigPublicationPlan { + .collect::>(); + workspace_skill_roots.sort_by_key(|root| root.precedence); + Ok(PluginCapabilityPublicationPlan { workspace_root, generation_key: generation_key.to_string(), + publication, registrations, routes, runtime_agent_keys, @@ -294,137 +319,126 @@ fn native_tool_baseline( } } -pub(crate) fn active_generation_key(workspace_root: &Path) -> Option { +pub(crate) fn active_generation_key(workspace_root: &Path, route_owner: &str) -> Option { let root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); skill_generations() .read() .ok()? - .get(&root) + .get(&(root, route_owner.to_string())) .map(|generation| generation.generation_key.clone()) } #[cfg(test)] mod tests { use super::*; - use bitfun_runtime_ports::{ - HookFunctionConfigContribution, HookFunctionConfigContributor, - HookFunctionContributorOutcome, HookFunctionGeneration, HookFunctionPluginIdentity, - HookFunctionRegistrationBatch, HookFunctionToolRegistration, + use bitfun_product_domains::plugin_capabilities::{ + PluginAgentProjection, PluginContributorIdentity, PluginSkillRootContribution, }; - use serde_json::{json, Map}; - - fn plugin() -> HookFunctionPluginIdentity { - HookFunctionPluginIdentity { - id: Some("deveco-harness".to_string()), - spec: "D:/code/deveco_harness".to_string(), - entry: "D:/code/deveco_harness/dist/index.js".to_string(), - index: 0, - } + + const OPENCODE_ROUTE_OWNER: &str = "opencode-plugin-config"; + + fn publication(ecosystem: &str) -> PluginPublicationIdentity { + PluginPublicationIdentity::new( + ecosystem, + format!("{ecosystem}-plugin"), + format!("{ecosystem}-plugin-config"), + ) } - fn registration_batch(config: Map) -> HookFunctionRegistrationBatch { - let plugin = plugin(); - HookFunctionRegistrationBatch { - generation: HookFunctionGeneration { - instance_id: "projection-test".to_string(), - generation_key: "projection-generation".to_string(), - revision: "projection-revision".to_string(), - }, - config: config.clone(), - config_contributors: vec![HookFunctionConfigContributor { - plugin: plugin.clone(), - outcome: HookFunctionContributorOutcome::Applied, - }], - config_contributions: vec![HookFunctionConfigContribution { - plugin: plugin.clone(), - outcome: HookFunctionContributorOutcome::Applied, - config, - }], - diagnostics: Vec::new(), - hooks: Vec::new(), - tools: vec![HookFunctionToolRegistration { - registration_id: "registration-build-project".to_string(), - id: "build_project".to_string(), - plugin: Some(plugin), - description: String::new(), - parameters: json!({"type": "object"}), - }], - } + fn contributor() -> PluginContributorIdentity { + PluginContributorIdentity::new( + "opencode-owner", + "D:/code/deveco_harness\nD:/code/deveco_harness/dist/index.js\n0", + "deveco-harness", + ) } #[test] fn generation_scoped_release_never_withdraws_a_replacement() { let workspace = tempfile::tempdir().expect("workspace"); - PluginConfigPublicationPlan::empty(workspace.path(), "generation-a").commit(); + PluginCapabilityPublicationPlan::empty( + workspace.path(), + "generation-a", + publication("opencode"), + ) + .commit(); assert!(!release_workspace_generation( workspace.path(), + OPENCODE_ROUTE_OWNER, "generation-b" )); assert_eq!( - active_generation_key(workspace.path()).as_deref(), + active_generation_key(workspace.path(), OPENCODE_ROUTE_OWNER).as_deref(), Some("generation-a") ); assert!(release_workspace_generation( workspace.path(), + OPENCODE_ROUTE_OWNER, "generation-a" )); - assert_eq!(active_generation_key(workspace.path()), None); + assert_eq!( + active_generation_key(workspace.path(), OPENCODE_ROUTE_OWNER), + None + ); } #[test] - fn publishes_plugin_skill_roots_to_all_workspace_agents() { + fn keeps_skill_generations_isolated_by_publication_owner() { let workspace = tempfile::tempdir().expect("workspace"); - let skill_root = tempfile::tempdir().expect("plugin skill root"); - let config = json!({"skills": {"paths": [skill_root.path()]}}) - .as_object() - .expect("config object") - .clone(); - let batch = registration_batch(config); - let projection = - bitfun_opencode_adapter::project_plugin_config(workspace.path(), &Map::new(), &batch) - .expect("OpenCode projection"); - let plan = prepare_projection(workspace.path(), "skill-only-generation", projection) - .expect("skill-only plugin publication"); - 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() - ); + let first_root = tempfile::tempdir().expect("first skill root"); + let second_root = tempfile::tempdir().expect("second skill root"); + for (ecosystem, root) in [("ecosystem-a", &first_root), ("ecosystem-b", &second_root)] { + prepare( + workspace.path(), + &format!("{ecosystem}-generation"), + publication(ecosystem), + PluginCapabilityProjection { + agents: Vec::new(), + skill_roots: vec![PluginSkillRootContribution { + path: root.path().to_path_buf(), + precedence: 0, + }], + }, + ) + .expect("skill publication") + .commit(); } - release_workspace(workspace.path()); + + let roots = skill_roots_for_agent(Some(workspace.path()), None); + assert_eq!(roots.len(), 2); + release_workspace(workspace.path(), "ecosystem-a-plugin-config"); + let roots = skill_roots_for_agent(Some(workspace.path()), None); + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].path, second_root.path()); + release_workspace(workspace.path(), "ecosystem-b-plugin-config"); } #[test] fn materializes_projected_agent_fields_and_plugin_tool_permissions() { - let config = json!({ - "agent": { - "build": { - "mode": "primary", - "temperature": 0.7, - "description": "Build projects", - "prompt": "Build prompt", - "permission": {"build_project": "allow"} - } - } - }) - .as_object() - .expect("config object") - .clone(); - let batch = registration_batch(config); - let projection = bitfun_opencode_adapter::project_plugin_config( + let contributor = contributor(); + let tool = PluginToolRef::new(contributor.clone(), "build_project"); + let projection = PluginCapabilityProjection { + agents: vec![PluginAgentProjection { + contributor, + logical_id: "build".to_string(), + description: "Build projects".to_string(), + prompt: "Build prompt".to_string(), + mode: ExternalSubagentMode::Primary, + hidden: false, + temperature: Some(0.7), + permission_constraints: Default::default(), + plugin_tools: vec![tool.clone()], + }], + skill_roots: Vec::new(), + }; + let plan = prepare( Path::new("C:/workspace"), - &Map::new(), - &batch, + "generation-1", + publication("opencode"), + projection, ) - .expect("OpenCode projection"); - let plan = prepare_projection(Path::new("C:/workspace"), "generation-1", projection) - .expect("publication"); + .expect("publication"); assert_eq!(plan.registrations.len(), 1); let build = &plan.registrations[0]; @@ -448,10 +462,9 @@ mod tests { assert!(plan .runtime_agent_keys .iter() - .all(|key| is_plugin_agent_runtime_key(key))); + .all(|key| is_agent_runtime_key_for_namespace(key, "opencode-plugin"))); assert_eq!( - plan.allowed_runtime_agent_keys_for_tool(&batch.tools[0]) - .expect("tool access"), + plan.allowed_runtime_agent_keys_for_tool(&tool), plan.runtime_agent_keys ); } diff --git a/src/crates/assembly/core/src/plugin_host.rs b/src/crates/assembly/core/src/plugin_host.rs index 3fd9752358..b502ad9609 100644 --- a/src/crates/assembly/core/src/plugin_host.rs +++ b/src/crates/assembly/core/src/plugin_host.rs @@ -61,6 +61,26 @@ impl CapturedPluginGeneration { const BUN_HOST_ENTRY_ENV: &str = "BITFUN_OPENCODE_BUN_HOST_ENTRY"; const BUN_COMMAND_ENV: &str = "BITFUN_BUN_COMMAND"; +const OPENCODE_PLUGIN_ECOSYSTEM: &str = "opencode"; +const OPENCODE_PLUGIN_RUNTIME_NAMESPACE: &str = "opencode-plugin"; +const OPENCODE_PLUGIN_ROUTE_OWNER: &str = "opencode-plugin-config"; + +fn opencode_plugin_publication_identity( +) -> crate::plugin_capability_publication::PluginPublicationIdentity { + crate::plugin_capability_publication::PluginPublicationIdentity::new( + OPENCODE_PLUGIN_ECOSYSTEM, + OPENCODE_PLUGIN_RUNTIME_NAMESPACE, + OPENCODE_PLUGIN_ROUTE_OWNER, + ) +} + +pub(crate) fn is_opencode_plugin_agent_runtime_key(runtime_agent_key: &str) -> bool { + crate::plugin_capability_publication::is_agent_runtime_key_for_namespace( + runtime_agent_key, + OPENCODE_PLUGIN_RUNTIME_NAMESPACE, + ) +} + static PLUGIN_HOST: OnceCell>> = OnceCell::const_new(); static PLUGIN_HOST_LIFECYCLE_LOCK: OnceCell> = OnceCell::const_new(); static PLUGIN_HOST_SHUTDOWN_REPORT: OnceCell>> = @@ -531,19 +551,29 @@ pub async fn ensure_configured_plugin_instance( }) }; if let Some(instance) = reusable_instance { - if crate::plugin_config_publication::active_generation_key(&canonical_directory).as_deref() + if crate::plugin_capability_publication::active_generation_key( + &canonical_directory, + OPENCODE_PLUGIN_ROUTE_OWNER, + ) + .as_deref() != Some(instance.generation_key.as_str()) { - let publication = crate::plugin_config_publication::prepare( + let registration_batch = instance.registration_batch.as_ref().ok_or_else(|| { + crate::BitFunError::ProcessError( + "Reusable plugin instance is missing its typed registration batch".to_string(), + ) + })?; + let projection = bitfun_opencode_adapter::project_plugin_config( &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(), - ) - })?, + registration_batch, + ) + .map_err(|error| crate::BitFunError::Validation(error.to_string()))?; + let publication = crate::plugin_capability_publication::prepare( + &canonical_directory, + &instance.generation_key, + opencode_plugin_publication_identity(), + projection, )?; crate::plugin_hook_bridge::commit_plugin_generation( &crate::native_hooks::plugin_hook_registry(&comparable_directory), @@ -704,12 +734,20 @@ pub async fn ensure_configured_plugin_instance( return Err(error); } }; - let config_publication = match crate::plugin_config_publication::prepare( + let projected_config = bitfun_opencode_adapter::project_plugin_config( &canonical_directory, - &generation_key, &initial_config, ®istration_batch, - ) { + ) + .map_err(|error| crate::BitFunError::Validation(error.to_string())); + let config_publication = match projected_config.and_then(|projection| { + crate::plugin_capability_publication::prepare( + &canonical_directory, + &generation_key, + opencode_plugin_publication_identity(), + projection, + ) + }) { Ok(publication) => publication, Err(error) => { discard_opening_plugin_instance( @@ -937,7 +975,7 @@ async fn withdraw_configured_plugin_workspace(directory: &Path) { 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_publication::release_workspace(canonical); + crate::plugin_capability_publication::release_workspace(canonical, OPENCODE_PLUGIN_ROUTE_OWNER); let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); return; @@ -1038,10 +1076,17 @@ async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_gene &instance.generation_key, ) .await; - if crate::plugin_config_publication::active_generation_key(&instance.directory).as_deref() + if crate::plugin_capability_publication::active_generation_key( + &instance.directory, + OPENCODE_PLUGIN_ROUTE_OWNER, + ) + .as_deref() == Some(instance.generation_key.as_str()) { - crate::plugin_config_publication::release_workspace(&instance.directory); + crate::plugin_capability_publication::release_workspace( + &instance.directory, + OPENCODE_PLUGIN_ROUTE_OWNER, + ); } if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { bridge.cancel_instance_streams(&instance.instance_id).await; @@ -1061,7 +1106,10 @@ async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_gene }); if !has_replacement { crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace_scope); - crate::plugin_config_publication::release_workspace(&canonical); + crate::plugin_capability_publication::release_workspace( + &canonical, + OPENCODE_PLUGIN_ROUTE_OWNER, + ); crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); } } @@ -1205,8 +1253,11 @@ fn schedule_plugin_instance_retirement( } instance.clone() }; - if crate::plugin_config_publication::active_generation_key(&snapshot.directory) - .as_deref() + if crate::plugin_capability_publication::active_generation_key( + &snapshot.directory, + OPENCODE_PLUGIN_ROUTE_OWNER, + ) + .as_deref() == Some(snapshot.generation_key.as_str()) { if let Some(instance) = instances.lock().await.get_mut(&instance_key) { @@ -1278,8 +1329,9 @@ async fn retire_plugin_instance( }; close_plugin_host_ptys(&instance.instance_id).await; if closed { - crate::plugin_config_publication::release_workspace_generation( + crate::plugin_capability_publication::release_workspace_generation( &instance.directory, + OPENCODE_PLUGIN_ROUTE_OWNER, &instance.generation_key, ); } @@ -1702,7 +1754,10 @@ pub async fn shutdown_configured_plugin_host( &instance.generation_key, ) .await; - crate::plugin_config_publication::release_workspace(&instance.directory); + crate::plugin_capability_publication::release_workspace( + &instance.directory, + OPENCODE_PLUGIN_ROUTE_OWNER, + ); } let workspaces = instances .values() @@ -1745,7 +1800,7 @@ async fn register_plugin_tools( revision: &str, config_fingerprint: &str, registration_batch: &HookFunctionRegistrationBatch, - projection: &crate::plugin_config_publication::PluginConfigPublicationPlan, + projection: &crate::plugin_capability_publication::PluginCapabilityPublicationPlan, ) -> crate::BitFunResult> { let tools = ®istration_batch.tools; if tools.is_empty() { @@ -1765,7 +1820,9 @@ async fn register_plugin_tools( 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)?; + let tool_ref = bitfun_opencode_adapter::project_plugin_tool_ref(tool) + .map_err(|error| crate::BitFunError::Validation(error.to_string()))?; + let allowed_runtime_agent_keys = projection.allowed_runtime_agent_keys_for_tool(&tool_ref); if !seen_ids.insert(tool.id.clone()) { return Err(crate::BitFunError::Validation(format!( "Plugin tool id is duplicated in the registration batch: {}", diff --git a/src/crates/contracts/product-domains/AGENTS-CN.md b/src/crates/contracts/product-domains/AGENTS-CN.md index 30ae0f346b..3a54449e86 100644 --- a/src/crates/contracts/product-domains/AGENTS-CN.md +++ b/src/crates/contracts/product-domains/AGENTS-CN.md @@ -24,7 +24,8 @@ ports;具体 runtime 行为不属于本 crate。 和 Git/AI port trait。 - `plugin-source` 可以拥有 BitFun 插件包清单数据结构、来源标识、工作区信任记录和纯信任版本变更规则。 - `external-sources` 可以拥有开放生态/来源标识、类型化能力 provider 端口、目录 DTO 与版本敏感冲突指纹; - provider 刷新、文件观察、偏好持久化和生命周期协调属于 assembly、services 或 adapters。 + 也可以拥有可执行插件 adapter 输出的生态无关 Agent、Tool 引用与 Skill 根贡献 DTO。这些 DTO 不定义来源格式、 + Host 协议、执行句柄或生命周期;provider 刷新、文件观察、偏好持久化和生命周期协调属于 assembly、services 或 adapters。 - 具体 filesystem writes、marker IO、host dispatch、worker side effect、compile orchestration、`PathManager` integration、 concrete Git/AI service、provider acquisition 和 transport error mapping 均属于本 crate 外部。 diff --git a/src/crates/contracts/product-domains/AGENTS.md b/src/crates/contracts/product-domains/AGENTS.md index 8ed605e3c3..74933f2f41 100644 --- a/src/crates/contracts/product-domains/AGENTS.md +++ b/src/crates/contracts/product-domains/AGENTS.md @@ -37,8 +37,11 @@ policies, and narrow ports; concrete runtime behavior belongs outside this crate transitions. - `external-sources` may own open ecosystem/source identifiers, typed capability-provider ports, catalog DTOs, and version-sensitive conflict - fingerprints. Provider refresh, filesystem watching, persistence, and - lifecycle coordination belong to assembly, services, or adapters. + fingerprints. It also owns the provider-neutral Agent, Tool-reference, and + Skill-root contribution DTOs produced by executable plugin adapters. These + DTOs do not define source formats, Host protocols, execution handles, or + lifecycle. Provider refresh, filesystem watching, persistence, and lifecycle + coordination belong to assembly, services, or adapters. - Concrete filesystem writes, marker IO, host dispatch, worker side effects, compile orchestration, `PathManager` integration, concrete Git/AI services, provider acquisition, and transport error mapping must stay outside diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index 0606459cbc..d871e21ac2 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -37,6 +37,9 @@ pub mod external_sources; #[cfg(feature = "external-sources")] pub mod external_subagents; +#[cfg(feature = "external-sources")] +pub mod plugin_capabilities; + #[cfg(feature = "external-sources")] pub mod workspace_references; diff --git a/src/crates/contracts/product-domains/src/plugin_capabilities.rs b/src/crates/contracts/product-domains/src/plugin_capabilities.rs new file mode 100644 index 0000000000..db825f9bcc --- /dev/null +++ b/src/crates/contracts/product-domains/src/plugin_capabilities.rs @@ -0,0 +1,97 @@ +//! Provider-neutral capability contributions produced by executable plugin adapters. +//! +//! Ecosystem-specific modules retain source parsing, contributor attribution, +//! validation, Host protocols, and lifecycle. Product assembly consumes these +//! values to publish existing Agent and Skill capabilities without receiving +//! raw ecosystem configuration or depending on an adapter-specific DTO. + +use crate::external_subagents::ExternalSubagentMode; +use crate::tool_permissions::PermissionConstraintLayer; +use std::path::PathBuf; + +/// Adapter-defined identity for one plugin contributor. +/// +/// `identity_key` scopes ownership and equality. `behavior_key` is the stable +/// input used by product publication to preserve runtime identity across +/// adapter refactors. They are separate because two contributors may expose +/// the same behavior identity while remaining distinct owners. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PluginContributorIdentity { + identity_key: String, + behavior_key: String, + label: String, +} + +impl PluginContributorIdentity { + pub fn new( + identity_key: impl Into, + behavior_key: impl Into, + label: impl Into, + ) -> Self { + Self { + identity_key: identity_key.into(), + behavior_key: behavior_key.into(), + label: label.into(), + } + } + + pub fn identity_key(&self) -> &str { + &self.identity_key + } + + pub fn behavior_key(&self) -> &str { + &self.behavior_key + } + + pub fn label(&self) -> &str { + &self.label + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PluginToolRef { + contributor: PluginContributorIdentity, + id: String, +} + +impl PluginToolRef { + pub fn new(contributor: PluginContributorIdentity, id: impl Into) -> Self { + Self { + contributor, + id: id.into(), + } + } + + pub fn contributor(&self) -> &PluginContributorIdentity { + &self.contributor + } + + pub fn id(&self) -> &str { + &self.id + } +} + +#[derive(Debug, Clone)] +pub struct PluginAgentProjection { + pub contributor: PluginContributorIdentity, + pub logical_id: String, + pub description: String, + pub prompt: String, + pub mode: ExternalSubagentMode, + pub hidden: bool, + pub temperature: Option, + pub permission_constraints: PermissionConstraintLayer, + pub plugin_tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginSkillRootContribution { + pub path: PathBuf, + pub precedence: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct PluginCapabilityProjection { + pub agents: Vec, + pub skill_roots: Vec, +} diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index 0ba4a81aa8..d01b3330bf 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -6,5 +6,7 @@ mod external_hook_catalog_contracts; mod external_hook_contribution_contracts; #[path = "external_source_contracts/external_source_contracts.rs"] mod external_source_contracts; +#[path = "external_source_contracts/plugin_capability_contracts.rs"] +mod plugin_capability_contracts; #[path = "external_source_contracts/workspace_reference_contracts.rs"] mod workspace_reference_contracts; diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts/plugin_capability_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/plugin_capability_contracts.rs new file mode 100644 index 0000000000..98278bd7cf --- /dev/null +++ b/src/crates/contracts/product-domains/tests/external_source_contracts/plugin_capability_contracts.rs @@ -0,0 +1,29 @@ +use bitfun_product_domains::plugin_capabilities::{ + PluginCapabilityProjection, PluginContributorIdentity, PluginToolRef, +}; + +#[test] +fn contributor_ownership_and_behavior_identities_remain_distinct() { + let first = PluginContributorIdentity::new("owner:first", "shared-behavior", "Plugin"); + let second = PluginContributorIdentity::new("owner:second", "shared-behavior", "Plugin"); + + assert_ne!(first, second); + assert_eq!(first.behavior_key(), second.behavior_key()); + assert_eq!(first.label(), "Plugin"); +} + +#[test] +fn tool_references_are_provider_neutral_and_owner_scoped() { + let contributor = PluginContributorIdentity::new( + "owner:second-ecosystem", + "second-plugin-behavior", + "Second ecosystem plugin", + ); + let tool = PluginToolRef::new(contributor.clone(), "run_code"); + let projection = PluginCapabilityProjection::default(); + + assert_eq!(tool.contributor(), &contributor); + assert_eq!(tool.id(), "run_code"); + assert!(projection.agents.is_empty()); + assert!(projection.skill_roots.is_empty()); +}