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..9d8eac9893 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 作为模型展示结果,后续有真实消费方时再扩展小型展示契约。 @@ -354,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. 大类风险 | 大类 | 整体风险 | 主要控制点 | @@ -386,7 +401,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/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/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..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,6 +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_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 2b5f785545..d8f26ac57d 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,11 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeConfigSnapshotError', 'bitfun-core live Plugin Host config validation and OpenCode config snapshot contract tests', ), + ...[ + 'project_plugin_config', + 'project_plugin_tool_ref', + 'OpenCodePluginConfigProjectionError', + ].map(opencodePluginConfigProjectionEntry), opencodeAdapterEntry( 'OpenCodeCommandProvider', 'bitfun-core external source composition root and OpenCode command adapter tests', @@ -582,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', @@ -1339,7 +1382,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, }, { @@ -1393,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 3179a8e229..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,10 +1623,24 @@ 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,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( - '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', + 'plugin capability projection public API must stay limited to the reviewed provider-neutral contribution DTOs', ); } const opencodeInstructionSymbols = new Set([ 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 585d7769da..4c6b872d48 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,11 @@ - 依赖 `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 平台;OpenCode contributor 解析和 Tool registration 转换仍留在本 crate, + 不得让 OpenCode 专属 DTO 进入通用发布模块。 - 当前源码探测只识别测试覆盖的声明式语法子集,不是通用 JS/TS 解析器;没有可识别入口的包和已识别但不支持的 hook 必须返回诊断,其他语法不属于当前兼容范围。 - 未支持的 OpenCode 能力必须显式返回类型化诊断或不支持状态,不得静默忽略。 @@ -44,8 +49,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` 或 OpenCode 专属的 `bitfun-core/plugin_host` 组装路径)消费本 crate;增加其他消费方时必须同步 + 边界脚本和聚焦组装路径测试。 - 本 crate 不得依赖 Codex、Claude Code 或其他生态 adapter。新生态是由 Product Assembly 注册的同级 adapter, 不是本 adapter 的模式。 - 生产 crate 不得直接依赖 `bitfun_opencode_adapter` 内部类型。未支持能力必须诊断化, @@ -59,5 +65,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..090bc8defe 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,13 @@ 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 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 @@ -106,8 +115,9 @@ 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 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 @@ -124,5 +134,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..fc557f9fb6 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,9 @@ 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, 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 new file mode 100644 index 0000000000..438a0e4421 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/plugin_config_projection.rs @@ -0,0 +1,1194 @@ +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, +}; +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, 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: PluginContributorIdentity, + outcome: ContributorOutcome, +} + +#[derive(Debug, Clone)] +struct ConfigContribution { + plugin: PluginContributorIdentity, + 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), 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 { + let contributors = registration_batch + .config_contributors + .iter() + .map(|entry| { + Ok(ConfigContributor { + plugin: project_plugin_identity(&entry.plugin)?, + outcome: entry.outcome.into(), + }) + }) + .collect::, OpenCodePluginConfigProjectionError>>()?; + if contributors.is_empty() { + return Ok(PluginCapabilityProjection::default()); + } + + let config = ®istration_batch.config; + let contributions = registration_batch + .config_contributions + .iter() + .map(|entry| { + Ok(ConfigContribution { + plugin: project_plugin_identity(&entry.plugin)?, + outcome: entry.outcome.into(), + config: entry.config.clone(), + }) + }) + .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")?; + 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| PluginToolRef::new(contributor, id)) + }) + .collect(); + agents.push(PluginAgentProjection { + 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(PluginCapabilityProjection { + 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 { + 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_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: &PluginContributorIdentity, +) -> 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 = project_plugin_tool_ref(tool)?; + result + .entry(tool_ref.contributor().clone()) + .or_default() + .insert(tool_ref.id().to_string()); + } + 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(PluginSkillRootContribution { + 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 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"); + 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.behavior_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(PluginToolRef::id) + .collect::>(), + vec!["first_tool", "second_tool"] + ); + assert_eq!(plan.contributor.label(), "second"); + assert_eq!( + plan.plugin_tools + .iter() + .map(PluginToolRef::id) + .collect::>(), + vec!["second_tool"] + ); + } + + #[test] + fn reattributes_deleted_and_recreated_agents_and_permissions() { + 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() + .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 = project_plugin_identity(&plugin("first")).unwrap(); + let second = project_plugin_identity(&plugin("second")).unwrap(); + 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..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_projection::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 d7a4b7112e..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_projection::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 1fc2c50c42..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_projection; +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 513ec20d96..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_projection::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_projection::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_capability_publication.rs b/src/crates/assembly/core/src/plugin_capability_publication.rs new file mode 100644 index 0000000000..11da4e27fd --- /dev/null +++ b/src/crates/assembly/core/src/plugin_capability_publication.rs @@ -0,0 +1,485 @@ +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_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}; + +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, +} + +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)] +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 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>, +} + +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(), + 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: &PluginToolRef, + ) -> BTreeSet { + self.tool_runtime_agent_keys + .get(tool) + .cloned() + .unwrap_or_default() + } + + pub(crate) fn commit(self) { + get_agent_registry().replace_external_subagent_route_overlay( + &self.workspace_root, + &self.publication.route_owner, + self.registrations, + self.routes, + ); + let mut generations = skill_generations() + .write() + .expect("plugin skill generation lock poisoned"); + generations.insert( + (self.workspace_root, self.publication.route_owner), + PublishedSkillGeneration { + generation_key: self.generation_key, + workspace_roots: self.workspace_skill_roots, + }, + ); + } +} + +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, route_owner); + skill_generations() + .write() + .expect("plugin skill generation lock poisoned") + .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); + let mut generations = skill_generations() + .write() + .expect("plugin skill generation lock poisoned"); + if generations + .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, route_owner); + generations.remove(&(workspace_root, route_owner.to_string())); + 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); + let generations = skill_generations() + .read() + .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, + 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(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(); + 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().to_string()) + .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.behavior_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!("{}:{digest}", publication.runtime_namespace)); + 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!( + "{}:{}:{}", + publication.ecosystem_id, + hex::encode(Sha256::digest( + projected.contributor.behavior_key().as_bytes() + )), + projected.logical_id.to_ascii_lowercase() + ), + ecosystem_id: ecosystem_id.clone(), + 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 mut workspace_skill_roots = projection + .skill_roots + .into_iter() + .map(|root| PluginSkillRootContribution { + path: root.path, + precedence: root.precedence, + }) + .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, + 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, route_owner: &str) -> Option { + let root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + skill_generations() + .read() + .ok()? + .get(&(root, route_owner.to_string())) + .map(|generation| generation.generation_key.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::plugin_capabilities::{ + PluginAgentProjection, PluginContributorIdentity, PluginSkillRootContribution, + }; + + const OPENCODE_ROUTE_OWNER: &str = "opencode-plugin-config"; + + fn publication(ecosystem: &str) -> PluginPublicationIdentity { + PluginPublicationIdentity::new( + ecosystem, + format!("{ecosystem}-plugin"), + format!("{ecosystem}-plugin-config"), + ) + } + + 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"); + 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(), 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(), OPENCODE_ROUTE_OWNER), + None + ); + } + + #[test] + fn keeps_skill_generations_isolated_by_publication_owner() { + let workspace = tempfile::tempdir().expect("workspace"); + 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(); + } + + 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 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"), + "generation-1", + publication("opencode"), + 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_agent_runtime_key_for_namespace(key, "opencode-plugin"))); + assert_eq!( + plan.allowed_runtime_agent_keys_for_tool(&tool), + 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_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_host.rs b/src/crates/assembly/core/src/plugin_host.rs index d03b27b8b1..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,26 +551,36 @@ 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_capability_publication::active_generation_key( + &canonical_directory, + OPENCODE_PLUGIN_ROUTE_OWNER, + ) + .as_deref() != Some(instance.generation_key.as_str()) { - let projection = crate::plugin_config_projection::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), &comparable_directory, instance.hook_commit_token.as_ref(), ); - projection.commit(); + publication.commit(); } log::debug!( "Configured plugin host instance reused: generation={}, instance_id={}", @@ -704,13 +734,21 @@ pub async fn ensure_configured_plugin_instance( return Err(error); } }; - let config_projection = match crate::plugin_config_projection::prepare( + let projected_config = bitfun_opencode_adapter::project_plugin_config( &canonical_directory, - &generation_key, &initial_config, ®istration_batch, - ) { - Ok(projection) => projection, + ) + .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( &client, @@ -723,7 +761,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 +803,7 @@ pub async fn ensure_configured_plugin_instance( &revision, &config_fingerprint, ®istration_batch, - &config_projection, + &config_publication, ) .await { @@ -838,7 +876,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 +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_projection::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_projection::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_projection::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_projection::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_projection::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_projection::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_projection::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_projection::PluginConfigProjectionPlan, + 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()); +}